import { html } from 'lit';
import { ref, createRef } from 'lit/directives/ref.js';
import { BaseView } from './baseView.js';
import { CustomDate, sleep } from './helpers.js';
import { formatTrainTypes, formatLineDisplayName } from './formatters.js'
import { cachedCoachSequence } from './coach-sequence/index.js';
export class JourneysCanvas extends BaseView {
static properties = {
canvasState: { state: true },
};
static colors = {
fill: {
'tram': '#cc5555',
'subway': '#5555cc',
'suburban': '#55aa55',
'nationalExpress': '#fff',
'national': '#fff',
'regionalExpress': '#888',
'regional': '#888',
'bus': '#aa55aa',
default: '#888'
},
text: {
'nationalExpress': '#ee3333',
'national': '#ee3333',
default: '#fff'
},
cancelFill: {
'tram': '#fff',
default: '#cc4444ff',
},
icon: {
'walk': 'directions_walk',
'transfer': 'directions_transfer',
'subway': 'directions_subway',
'bus': 'directions_bus',
'tram': 'tram',
default: 'train'
},
loadFactor: {
'low-to-medium': [ '#777', '#ccc', '#ccc' ],
'high': [ '#777', '#777', '#ccc' ],
'very-high': [ '#ee8800', '#ee8800', '#ee8800' ],
'exceptionally-high': [ '#cc3300', '#cc3300', '#cc3300' ],
}
};
constructor () {
super();
this.canvasRef = createRef();
this.canvasState = {
handlersConnected: false,
textCache: {},
animationInterval: null,
offsetX: 0,
dpr: (window.devicePixelRatio || 1),
lastAnimationUpdate: 0,
firstDeparture: 0,
scaleFactor: 0,
lastArrival: 0,
rectWidth: 0,
padding: 0,
rectWidthWithPadding: 0
};
this.canvasState.offsetX = (window.innerWidth / this.canvasState.dpr) > 600 ? 140 : 80;
}
updated (previous, viewName) {
super.updated(previous, viewName);
if (isDevServer) console.info(`${viewName}(canvasState):`, this.canvasState);
}
resetCanvasPosition = () => {
this.canvasState.offsetX = (window.innerWidth / this.canvasState.dpr) > 600 ? 140 : 80;
};
getCanvas = () => html``;
connectCanvas = () => {
this.resizeHandler();
if (!this.canvasState.handlersConnected) {
window.addEventListener('mouseup', this.mouseUpHandler);
window.addEventListener('touchend', this.mouseUpHandler);
window.addEventListener('mousemove', this.mouseMoveHandler);
window.addEventListener('touchmove', this.mouseMoveHandler);
window.addEventListener('resize', this.resizeHandler);
window.addEventListener('zoom', this.resizeHandler);
this.canvasState.handlersConnected = true;
}
};
disconnectCanvas = () => {
window.removeEventListener('mouseup', this.mouseUpHandler);
window.removeEventListener('touchend', this.mouseUpHandler);
window.removeEventListener('mousemove', this.mouseMoveHandler);
window.removeEventListener('touchmove', this.mouseMoveHandler);
window.removeEventListener('resize', this.resizeHandler);
window.removeEventListener('zoom', this.resizeHandler);
this.canvasState.handlersConnected = false;
};
resizeHandler = () => {
const canvasElement = this.canvasRef.value;
if (!canvasElement) return true;
const canvasContext = canvasElement.getContext('2d');
this.canvasState.dpr = window.devicePixelRatio || 1;
this.canvasState.rectWidth = (window.innerWidth / this.canvasState.dpr) > 600 ? 100 : 80;
this.canvasState.padding = (window.innerWidth / this.canvasState.dpr) > 600 ? 20 : 5;
this.canvasState.rectWidthWithPadding = this.canvasState.rectWidth + 2 * this.canvasState.padding;
const rect = this.renderRoot.querySelector('header').getBoundingClientRect();
canvasElement.width = window.innerWidth * this.canvasState.dpr;
canvasElement.height = (window.innerHeight - rect.height) * this.canvasState.dpr;
canvasElement.style.width = `${window.innerWidth}px`;
canvasElement.style.height = `${window.innerHeight - rect.height - 4}px`;
canvasContext.restore();
canvasContext.save();
canvasContext.scale(this.canvasState.dpr, this.canvasState.dpr);
this.canvasState.lastAnimationUpdate = 0;
this.renderCanvas();
};
mouseUpHandler = event => {
if (this.canvasState.dragging && this.canvasState.isClick) {
const x = event.x || event.changedTouches[0].pageX;
event.preventDefault();
const num = Math.floor((x - this.canvasState.offsetX + 2 * this.canvasState.padding) / this.canvasState.rectWidthWithPadding) + this.viewState.indexOffset;
if (num >= 0) {
if (num < this.viewState.journeys.length) {
const j = this.viewState.journeys[num];
window.location = `#/j/${this.viewState.profile}/${j.refreshToken}`;
} else if (this.viewState.laterRef) {
this.moreJourneys('later');
}
} else if (this.viewState.earlierRef) {
this.moreJourneys('earlier');
}
}
this.canvasState.dragging = false;
this.canvasState.isClick = false;
};
mouseDownHandler = event => {
const x = event.x || event.changedTouches[0].pageX;
this.canvasState.dragStartMouse = x;
this.canvasState.dragStartOffset = this.canvasState.offsetX;
this.canvasState.dragging = true;
this.canvasState.isClick = true;
};
mouseMoveHandler = event => {
if (this.canvasState.dragging) {
event.preventDefault();
const x = event.x || event.changedTouches[0].pageX;
this.canvasState.offsetX = this.canvasState.dragStartOffset - (this.canvasState.dragStartMouse - x);
if (Math.abs(this.canvasState.dragStartMouse - x) > 20) this.canvasState.isClick = false;
this.renderCanvas();
return true;
}
};
makeTextCache = (text, color, fixedHeight) => {
const cacheCanvas = document.createElement('canvas');
const cacheCanvasContext = cacheCanvas.getContext('2d');
let height, width;
cacheCanvasContext.shadowColor = '#00000080';
if (fixedHeight) {
height = 15;
cacheCanvasContext.font = `${height}px sans-serif`;
width = cacheCanvasContext.measureText(text).width;
} else {
const measureAccuracy = 50;
cacheCanvasContext.font = `${measureAccuracy}px sans-serif`;
width = this.canvasState.rectWidth - 10;
height = Math.abs(measureAccuracy * (width / (1 - cacheCanvasContext.measureText(text).width)));
}
cacheCanvas.width = width * this.canvasState.dpr;
cacheCanvas.height = Math.ceil(height * 1.5) * this.canvasState.dpr;
cacheCanvasContext.scale(this.canvasState.dpr, this.canvasState.dpr);
cacheCanvasContext.font = `${height}px sans-serif`;
cacheCanvasContext.fillStyle = color;
cacheCanvasContext.fillText(text, 0, height);
return cacheCanvas;
};
getTextCache = (text, color, fixedHeight) => {
const index = `${text}|${color}|${this.canvasState.rectWidth}|${this.canvasState.dpr}|${fixedHeight}`;
if (!this.canvasState.textCache[index]) this.canvasState.textCache[index] = this.makeTextCache(text, color, fixedHeight);
return this.canvasState.textCache[index];
};
getColor = (type, leg, num) => {
if (type !== 'loadFactor') {
const product = leg.line?.product || 'walk';
return JourneysCanvas.colors[type][product] || JourneysCanvas.colors[type].default;
} else {
return JourneysCanvas.colors[type][leg][num];
}
};
getCoachSequences = async mode => {
if (this.isOffline !== false) return;
if (!['db', 'rmv'].includes(this.viewState.profile)) return;
const wasUpdating = this.isUpdating;
this.isUpdating = true;
if (isDevServer) console.info('JourneysCanvas(getCoachSequences): fetch start');
let timeout = 500;
for (const journey of this.viewState.journeys) {
for (const leg of journey.legs) {
if (!leg.line) continue;
const [category, number] = leg.line.name.split(" ");
if (category !== 'ICE') continue;
await sleep(timeout);
const coachSequence = cachedCoachSequence(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture, mode);
timeout = !coachSequence.cachedResponse ? 500 : 0;
await sleep(200);
this.renderCanvas();
}
}
if (isDevServer) console.info('JourneysCanvas(getCoachSequences): fetch end');
if (!wasUpdating) this.isUpdating = false;
};
getTrainTypeTexts = leg => {
if (!leg.line || !leg.line.name) return [];
const [category, number] = leg.line.name.split(" ");
const info = cachedCoachSequence(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture, 'onlyCached');
if (!info) return [];
return formatTrainTypes(info).split(" + ");
};
renderCanvas = () => {
const canvasElement = this.canvasRef.value;
if (!canvasElement) return true;
const canvasContext = canvasElement.getContext('2d');
const drawButton = mode => {
const buttonPath = new Path2D('M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z');
canvasContext.fillStyle = '#fff';
canvasContext.shadowColor = '#00000080';
canvasContext.save();
canvasContext.scale(3, 3);
if (mode === 'earlier') canvasContext.translate(x / 3 - 15, canvasElement.height / this.canvasState.dpr / 6 - 24);
if (mode === 'later') canvasContext.translate(x / 3 + 5, canvasElement.height / this.canvasState.dpr / 6);
if (mode === 'earlier') canvasContext.rotate(-Math.PI*1.5);
if (mode === 'later') canvasContext.rotate(Math.PI*1.5);
canvasContext.fill(buttonPath);
canvasContext.restore();
canvasContext.beginPath();
if (mode === 'earlier') canvasContext.arc(x - 80, canvasElement.height / this.canvasState.dpr / 2 - 35,50,0,2*Math.PI);
if (mode === 'later') canvasContext.arc(x + 50, canvasElement.height / this.canvasState.dpr / 2 - 35,50,0,2*Math.PI);
canvasContext.fillStyle = '#ffffff40';
canvasContext.fill();
canvasContext.strokeStyle = '#00000020';
canvasContext.stroke();
}
canvasContext.clearRect(0, 0, canvasElement.width / this.canvasState.dpr, canvasElement.height / this.canvasState.dpr);
let x = this.canvasState.offsetX - this.viewState.indexOffset * this.canvasState.rectWidthWithPadding, y;
const firstVisibleJourney = Math.max(0, Math.floor((-x + this.canvasState.padding) / this.canvasState.rectWidthWithPadding));
const numVisibleJourneys = Math.ceil(canvasElement.width / this.canvasState.dpr / this.canvasState.rectWidthWithPadding);
const visibleJourneys = this.viewState.journeys.slice(firstVisibleJourney, firstVisibleJourney + numVisibleJourneys);
if (!visibleJourneys.length) return;
const targetFirstDeparture = Number(visibleJourneys[0].legs[0].plannedDeparture);
const targetLastArrival = Math.max.apply(Math,
visibleJourneys.map(journey => journey.legs[journey.legs.length-1].plannedArrival)
.concat(visibleJourneys.map(journey => journey.legs[journey.legs.length-1].arrival)
));
const targetScaleFactor = 1 / (targetLastArrival - targetFirstDeparture) * (canvasElement.height - 64 * this.canvasState.dpr) / this.canvasState.dpr;
const now = new Date();
const factor = Math.min(.3, (now - this.canvasState.lastAnimationUpdate) / 20);
if (!this.canvasState.lastAnimationUpdate) {
this.canvasState.firstDeparture = Number(targetFirstDeparture);
this.canvasState.lastArrival = Number(targetLastArrival);
this.canvasState.scaleFactor = targetScaleFactor;
} else {
this.canvasState.firstDeparture = this.canvasState.firstDeparture + (targetFirstDeparture - this.canvasState.firstDeparture) * factor;
this.canvasState.lastArrival = this.canvasState.lastArrival + (targetLastArrival - this.canvasState.lastArrival) * factor;
this.canvasState.scaleFactor = this.canvasState.scaleFactor + (targetScaleFactor - this.canvasState.scaleFactor) * factor;
}
this.canvasState.lastAnimationUpdate = now;
if (Math.abs(this.canvasState.scaleFactor - targetScaleFactor) > 1
|| Math.abs(this.canvasState.firstDeparture - targetFirstDeparture) > 1
|| Math.abs(this.canvasState.lastArrival - targetLastArrival) > 1
) {
if (!this.canvasState.animationInterval) this.canvasState.animationInterval = setInterval(() => this.renderCanvas(), 16.6);
} else {
if (this.canvasState.animationInterval) {
clearInterval(this.canvasState.animationInterval);
this.canvasState.animationInterval = null;
}
}
let time = this.viewState.journeys[0].legs[0].plannedDeparture;
canvasContext.font = `${(window.innerWidth / this.canvasState.dpr) > 600 ? 20 : 15}px sans-serif`;
canvasContext.fillStyle = '#aaa';
while (time < this.canvasState.lastArrival) {
const y = (time - this.canvasState.firstDeparture) * this.canvasState.scaleFactor + 32;
canvasContext.fillText(time.formatTime(), (window.innerWidth / this.canvasState.dpr) > 600 ? 30 : 10, y);
canvasContext.fillRect(0, y, canvasElement.width / this.canvasState.dpr, 1);
time = new CustomDate(Number(time) + 3600000);//Math.floor(120/scaleFactor));
}
canvasContext.fillStyle = '#fa5';
y = (new Date() - this.canvasState.firstDeparture) * this.canvasState.scaleFactor + 32;
canvasContext.fillRect(0, y-2, canvasElement.width / this.canvasState.dpr, 5);
if (this.viewState.earlierRef) drawButton('earlier');
this.viewState.journeys.forEach(journey => {
journey.legs.reverse();
journey.legs.forEach(leg => {
if (Math.abs(leg.departureDelay) > 60 || Math.abs(leg.arrivalDelay) > 60) {
const duration = (leg.plannedArrival - leg.plannedDeparture) * this.canvasState.scaleFactor;
y = (leg.plannedDeparture - this.canvasState.firstDeparture) * this.canvasState.scaleFactor + 32;
canvasContext.fillStyle = '#44444480';
canvasContext.strokeStyle = '#ffffff80';
canvasContext.fillRect(x - this.canvasState.padding, y, this.canvasState.rectWidth, duration);
canvasContext.strokeRect(x - this.canvasState.padding, y, this.canvasState.rectWidth, duration);
}
});
x += this.canvasState.rectWidthWithPadding;
});
x = this.canvasState.offsetX - this.viewState.indexOffset * this.canvasState.rectWidthWithPadding;
this.viewState.journeys.forEach(journey => {
let xOffset = 0;
let nextLeg;
journey.legs.forEach(leg => {
if (nextLeg && nextLeg.departure < leg.arrival) xOffset -= 5;
x += xOffset;
const duration = ((leg.arrival || leg.plannedArrival) - (leg.departure || leg.plannedDeparture)) * this.canvasState.scaleFactor;
y = ((leg.departure || leg.plannedDeparture) - this.canvasState.firstDeparture) * this.canvasState.scaleFactor + 32;
canvasContext.shadowColor = '#00000060';
if (!this.settingsState.disableCanvasBlur) canvasContext.shadowBlur = 5;
if (leg.walking || leg.transfer) {
canvasContext.fillStyle = '#777';
canvasContext.fillRect(x + this.canvasState.rectWidth / 2 - this.canvasState.rectWidth / 10, y, this.canvasState.rectWidth / 5, duration);
} else {
canvasContext.fillStyle = this.getColor('fill', leg);
canvasContext.fillRect(x, y, this.canvasState.rectWidth, duration);
canvasContext.strokeStyle = this.getColor('text', leg);
canvasContext.strokeRect(x, y, this.canvasState.rectWidth, duration);
}
if (!this.settingsState.disableCanvasBlur) canvasContext.shadowBlur = 0;
let preRenderedText = this.getTextCache(formatLineDisplayName(leg.line), this.getColor('text', leg));
let offset = duration / 2;
if ((offset + preRenderedText.height / this.canvasState.dpr) < duration - 5) {
canvasContext.scale(1 / this.canvasState.dpr, 1 / this.canvasState.dpr);
canvasContext.drawImage(preRenderedText, this.canvasState.dpr * (x + 5), Math.floor(this.canvasState.dpr * (y + offset) - preRenderedText.height / 2.3));
canvasContext.scale(this.canvasState.dpr, this.canvasState.dpr);
offset += preRenderedText.height / this.canvasState.dpr / 1.3 + 5;
}
this.getTrainTypeTexts(leg).forEach(typeText => {
const preRenderedTypeText = this.getTextCache(typeText, '#555');
if ((offset + preRenderedText.height / this.canvasState.dpr) < duration) {
canvasContext.scale(1 / this.canvasState.dpr, 1 / this.canvasState.dpr);
canvasContext.drawImage(preRenderedTypeText, this.canvasState.dpr * (x + 5), Math.floor(this.canvasState.dpr * (y + offset) - preRenderedTypeText.height / 1.5));
canvasContext.scale(this.canvasState.dpr, this.canvasState.dpr);
offset += preRenderedText.height / this.canvasState.dpr / 1.5;
}
});
if (leg.cancelled) {
canvasContext.beginPath();
canvasContext.moveTo(x, y);
canvasContext.lineTo(x + this.canvasState.rectWidth, y + duration);
canvasContext.strokeStyle = this.getColor('cancelFill', leg);
canvasContext.lineWidth = 5;
canvasContext.stroke();
canvasContext.lineWidth = 1;
}
/* draw journey start and end time */
// note: leg order is reversed at this point in time
const times = [];
if (journey.legs.indexOf(leg) == journey.legs.length - 1) times.push([leg.departure || leg.plannedDeparture, y - 9.5]);
if (journey.legs.indexOf(leg) == 0) times.push([leg.arrival || leg.plannedArrival, y + duration + 7.5]);
times.forEach(([time, y]) => {
preRenderedText = this.getTextCache(time.formatTime(), '#fff', 15);
canvasContext.scale(1 / this.canvasState.dpr, 1 / this.canvasState.dpr);
canvasContext.drawImage(preRenderedText, Math.ceil(this.canvasState.dpr * (x + ((this.canvasState.rectWidth - preRenderedText.width/this.canvasState.dpr)) / 2)), this.canvasState.dpr * (y - 7.5));
canvasContext.scale(this.canvasState.dpr, this.canvasState.dpr);
});
if (leg.loadFactor && duration > 20) {
canvasContext.shadowColor = '#00000090';
if (!this.settingsState.disableCanvasBlur) canvasContext.shadowBlur = 2;
[ "#777", "#aaa", "#aaa" ];
for (let i = 0; i < 3; i++) {
canvasContext.beginPath();
canvasContext.fillStyle = this.getColor('loadFactor', leg.loadFactor, i);
canvasContext.arc(x + (i + 3) * this.canvasState.rectWidth / 8, y + duration - 9.5, 5, 0, 2 * Math.PI, false);
canvasContext.fill();
}
if (!this.settingsState.disableCanvasBlur) canvasContext.shadowBlur = 0;
}
x -= xOffset;
nextLeg = leg;
});
journey.legs.reverse();
x += this.canvasState.rectWidthWithPadding;
});
if (this.viewState.laterRef) drawButton('later');
};
}