342 lines
11 KiB
JavaScript
342 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
import { moreJourneys } from './journeysView.js';
|
|
import { dataStorage } from './app.js';
|
|
|
|
const padZeroes = (str) => ('00' + str).slice(-2);
|
|
|
|
const formatTime = (date) => {
|
|
date = new Date(date * 1000);
|
|
return `${padZeroes(date.getHours())}:${padZeroes(date.getMinutes())}`
|
|
};
|
|
|
|
const textFor = (leg) => leg.line && leg.line.name || "";
|
|
const typeTextFor = (leg) => {
|
|
let res = "(" + leg.line.trainType + ")";
|
|
while (res.length < 12) {
|
|
res = " " + res + " ";
|
|
}
|
|
return res;
|
|
};
|
|
const colorFor = (leg, type) => {
|
|
let product = leg.line && leg.line.product || "walk";
|
|
return colors[type][product] || colors[type].default;
|
|
};
|
|
|
|
const flatten = (arr) => [].concat(...arr);
|
|
|
|
const colors = {
|
|
fill: {
|
|
'tram': '#cc5555',
|
|
'subway': '#5555cc',
|
|
'suburban': '#55aa55',
|
|
'nationalExp': '#fff',
|
|
'national': '#fff',
|
|
'regionalExp': '#888',
|
|
'regional': '#888',
|
|
'bus': '#aa55aa',
|
|
default: '#888'
|
|
},
|
|
text: {
|
|
'nationalExp': '#ee3333',
|
|
'national': '#ee3333',
|
|
default: '#fff'
|
|
},
|
|
icon: {
|
|
'walk': 'directions_walk',
|
|
'transfer': 'directions_transfer',
|
|
'subway': 'directions_subway',
|
|
'bus': 'directions_bus',
|
|
'tram': 'tram',
|
|
default: 'train'
|
|
}
|
|
};
|
|
|
|
let rectWidth, padding, rectWidthWithPadding, canvas, ctx, dpr;
|
|
|
|
const canvasState = {
|
|
journeys: [],
|
|
offsetX: 0,
|
|
firstJourney: 1,
|
|
};
|
|
|
|
const textCache = {};
|
|
let textCacheWidth;
|
|
let textCacheDpr;
|
|
|
|
export const setupCanvas = (data, isUpdate) => {
|
|
if (!isUpdate) canvasState.offsetX = (window.innerWidth / dpr) > 600 ? 120 : 60;
|
|
canvas = document.getElementById('canvas');
|
|
ctx = canvas.getContext('2d');
|
|
canvasState.journeys = Object.keys(data.journeys).sort((a, b) => Number(a) - Number(b)).map(k => data.journeys[k]);
|
|
canvasState.reqId = data.reqId;
|
|
canvasState.firstJourney = Number(Object.keys(data.journeys).sort((a, b) => Number(a) - Number(b))[0]);
|
|
|
|
canvas.addEventListener('mousedown', mouseDownHandler, {passive: true});
|
|
canvas.addEventListener('touchstart', mouseDownHandler, {passive: true});
|
|
updateTextCache();
|
|
resizeHandler();
|
|
};
|
|
|
|
const addTextToCache = (text, color, fixedHeight) => {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.shadowColor = '#00000080';
|
|
|
|
let height, width
|
|
if (fixedHeight) {
|
|
height = 15;
|
|
ctx.font = `${height}px sans-serif`;
|
|
width = ctx.measureText(text).width;
|
|
} else {
|
|
const measureAccuracy = 50;
|
|
ctx.font = `${measureAccuracy}px sans-serif`;
|
|
width = rectWidth - 10;
|
|
height = Math.abs(measureAccuracy * (width / (1 - ctx.measureText(text).width)));
|
|
}
|
|
|
|
canvas.width = width * dpr;
|
|
canvas.height = Math.ceil(height * 1.5) * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
ctx.font = `${height}px sans-serif`;
|
|
ctx.fillStyle = color;
|
|
if (height > 10) {
|
|
ctx.fillText(text, 0, height);
|
|
textCache[text] = canvas;
|
|
}
|
|
};
|
|
|
|
const updateTextCache = () => {
|
|
textCache.length = 0;
|
|
textCacheWidth = rectWidth;
|
|
textCacheDpr = dpr;
|
|
for (let journey of canvasState.journeys) {
|
|
for (let leg of journey.legs) {
|
|
addTextToCache(textFor(leg), colorFor(leg, "text"));
|
|
if (leg.line && leg.line.trainType) addTextToCache(typeTextFor(leg), "#555");
|
|
|
|
let times = [];
|
|
if (journey.legs.indexOf(leg) == 0) times.push(leg.departure);
|
|
if (journey.legs.indexOf(leg) == journey.legs.length - 1) times.push(leg.arrival);
|
|
for (let time of times) {
|
|
addTextToCache(formatTime(time.prognosedTime || time.plannedTime), "#fff", 15);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const renderJourneys = () => {
|
|
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
|
|
let x = canvasState.offsetX, y;
|
|
|
|
let firstVisibleJourney = Math.max(0, Math.floor((-canvasState.offsetX + padding) / rectWidthWithPadding));
|
|
let numVisibleJourneys = Math.ceil(canvas.width / dpr / rectWidthWithPadding);
|
|
let visibleJourneys = canvasState.journeys.slice(firstVisibleJourney, firstVisibleJourney + numVisibleJourneys);
|
|
|
|
if (!visibleJourneys.length) return;
|
|
|
|
let firstDeparture = visibleJourneys[0].legs[0].departure.plannedTime;
|
|
let lastArrival = Math.max.apply(Math,
|
|
visibleJourneys.map(journey => journey.legs[journey.legs.length-1].arrival.plannedTime)
|
|
.concat(visibleJourneys.map(journey => journey.legs[journey.legs.length-1].arrival.prognosedTime))
|
|
);
|
|
let scaleFactor = 1/(lastArrival - firstDeparture) * (canvas.height - 64 * dpr) / dpr;
|
|
|
|
let time = canvasState.journeys[0].legs[0].departure.plannedTime;
|
|
|
|
ctx.font = `${(window.innerWidth / dpr) > 600 ? 20 : 15}px sans-serif`;
|
|
ctx.fillStyle = '#aaa';
|
|
while (time < lastArrival) {
|
|
let y = (time - firstDeparture) * scaleFactor + 32;
|
|
ctx.fillText(formatTime(time), (window.innerWidth / dpr) > 600 ? 30 : 10, y);
|
|
ctx.fillRect(0, y, canvas.width / dpr, 1);
|
|
time += Math.floor(120/scaleFactor);
|
|
}
|
|
ctx.fillStyle = '#fa5';
|
|
y = (Number(new Date()) / 1000 - firstDeparture) * scaleFactor + 32;
|
|
ctx.fillRect(0, y-2, canvas.width / dpr, 5);
|
|
|
|
const p = new Path2D('M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z');
|
|
ctx.fillStyle = '#fff';
|
|
ctx.shadowColor = '#00000080';
|
|
ctx.save();
|
|
ctx.scale(3, 3);
|
|
ctx.translate(x / 3 - 15, canvas.height / dpr / 6 - 24);
|
|
ctx.rotate(-Math.PI*1.5);
|
|
ctx.fill(p);
|
|
ctx.restore();
|
|
ctx.beginPath();
|
|
ctx.arc(x - 80,canvas.height / dpr / 2 - 35,50,0,2*Math.PI);
|
|
ctx.fillStyle = '#ffffff40';
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#00000020';
|
|
ctx.stroke();
|
|
|
|
for (let journey of canvasState.journeys) {
|
|
journey.legs.reverse();
|
|
for (let leg of journey.legs) {
|
|
let depDelayed = leg.departure.prognosedTime && leg.departure.prognosedTime != leg.departure.plannedTime
|
|
let arrDelayed = leg.arrival.prognosedTime && leg.arrival.prognosedTime != leg.arrival.plannedTime
|
|
if (depDelayed || arrDelayed) {
|
|
const start = leg.departure.plannedTime;
|
|
const stop = leg.arrival.plannedTime;
|
|
const duration = (stop - start) * scaleFactor;
|
|
|
|
y = (start - firstDeparture) * scaleFactor + 32;
|
|
|
|
ctx.fillStyle = '#44444480';
|
|
ctx.strokeStyle = '#ffffff80';
|
|
ctx.fillRect(x-padding, y, rectWidth, duration);
|
|
ctx.strokeRect(x-padding, y, rectWidth, duration);
|
|
}
|
|
}
|
|
x += rectWidthWithPadding;
|
|
}
|
|
|
|
x = canvasState.offsetX;
|
|
|
|
for (let journey of canvasState.journeys) {
|
|
for (let leg of journey.legs) {
|
|
const start = leg.departure.prognosedTime || leg.departure.plannedTime;
|
|
const stop = leg.arrival.prognosedTime || leg.arrival.plannedTime;
|
|
const duration = (stop - start) * scaleFactor;
|
|
|
|
y = (start - firstDeparture) * scaleFactor + 32;
|
|
|
|
ctx.shadowColor = '#00000060';
|
|
ctx.shadowBlur = 5;
|
|
|
|
if (leg.isWalking || leg.isTransfer) {
|
|
ctx.fillStyle = '#777';
|
|
ctx.fillRect(x + rectWidth / 2 - rectWidth / 10, y, rectWidth / 5, duration);
|
|
} else {
|
|
ctx.fillStyle = colorFor(leg, "fill");
|
|
ctx.fillRect(x, y, rectWidth, duration);
|
|
}
|
|
ctx.shadowBlur = 0;
|
|
|
|
|
|
let preRenderedText = textCache[textFor(leg)];
|
|
if ((preRenderedText.height / dpr) < duration - 5) {
|
|
ctx.scale(1 / dpr, 1 / dpr);
|
|
ctx.drawImage(preRenderedText, dpr * (x + 5), Math.floor(dpr * (y + duration / 2) - preRenderedText.height / 2.3));
|
|
ctx.scale(dpr, dpr);
|
|
}
|
|
if (leg.line && leg.line.trainType) {
|
|
let preRenderedTypeText = textCache[typeTextFor(leg)];
|
|
if ((preRenderedTypeText.height / dpr + preRenderedText.height / dpr) < duration - 5) {
|
|
ctx.scale(1 / dpr, 1 / dpr);
|
|
ctx.drawImage(preRenderedTypeText, dpr * (x + 5), Math.floor(dpr * (y + duration / 2 + preRenderedText.height / 2 + 10) - preRenderedTypeText.height / 2.3));
|
|
ctx.scale(dpr, dpr);
|
|
}
|
|
}
|
|
|
|
if (leg.cancelled) {
|
|
ctx.strokeStyle = '#cc4444ff';
|
|
ctx.lineWidth = 5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + rectWidth, y + duration);
|
|
ctx.stroke();
|
|
ctx.lineWidth = 1;
|
|
}
|
|
|
|
/* draw journey start and end time */
|
|
let time;
|
|
// note: leg order is reversed at this point in time
|
|
let times = [];
|
|
if (journey.legs.indexOf(leg) == journey.legs.length - 1) times.push([leg.departure, y - 9.5]);
|
|
if (journey.legs.indexOf(leg) == 0) times.push([leg.arrival, y + duration + 7.5]);
|
|
for (let [time, y] of times) {
|
|
preRenderedText = textCache[formatTime(time.prognosedTime || time.plannedTime)];
|
|
ctx.scale(1 / dpr, 1 / dpr);
|
|
ctx.drawImage(preRenderedText, Math.ceil(dpr * (x + ((rectWidth - preRenderedText.width/dpr)) / 2)), dpr * (y - 7.5)); //Math.floor(dpr * (x + rectWidth / 2) - preRenderedText.width), dpr * (y - 1));
|
|
ctx.scale(dpr, dpr);
|
|
}
|
|
}
|
|
|
|
journey.legs.reverse();
|
|
x += rectWidthWithPadding;
|
|
}
|
|
ctx.fillStyle = '#fff';
|
|
ctx.shadowColor = '#00000080';
|
|
ctx.save();
|
|
ctx.scale(3, 3);
|
|
ctx.translate(x / 3 + 5, canvas.height / dpr / 6);
|
|
ctx.rotate(Math.PI*1.5);
|
|
ctx.fill(p);
|
|
ctx.restore();
|
|
ctx.beginPath();
|
|
ctx.arc(x + 50,canvas.height / dpr / 2 - 35,50,0,2*Math.PI);
|
|
ctx.fillStyle = '#ffffff40';
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#00000020';
|
|
ctx.stroke();
|
|
};
|
|
|
|
const resizeHandler = () => {
|
|
dpr = window.devicePixelRatio || 1;
|
|
if (!document.getElementById('canvas')) return;
|
|
|
|
rectWidth = (window.innerWidth / dpr) > 600 ? 100 : 80;
|
|
padding = (window.innerWidth / dpr) > 600 ? 20 : 5;
|
|
rectWidthWithPadding = rectWidth + 2 * padding;
|
|
|
|
if (rectWidth !== textCacheWidth || dpr !== textCacheDpr) updateTextCache();
|
|
|
|
const rect = document.getElementById('header').getBoundingClientRect();
|
|
canvas.width = window.innerWidth * dpr;
|
|
canvas.height = (window.innerHeight - rect.height) * dpr;
|
|
canvas.style.width = `${window.innerWidth}px`;
|
|
canvas.style.height = `${window.innerHeight - rect.height - 4}px`;
|
|
|
|
ctx.restore();
|
|
ctx.save();
|
|
ctx.scale(dpr, dpr);
|
|
|
|
renderJourneys();
|
|
};
|
|
|
|
const mouseUpHandler = (evt) => {
|
|
let x = evt.x || evt.changedTouches[0].pageX;
|
|
if (canvasState.dragging && Math.abs(canvasState.dragStartMouse - x) < 20) {
|
|
let num = Math.floor((x - canvasState.offsetX + 2 * padding) / rectWidthWithPadding);
|
|
if (num >= 0) {
|
|
if (num < canvasState.journeys.length) {
|
|
window.location.hash += '/' + (num+canvasState.firstJourney);
|
|
} else {
|
|
moreJourneys(canvasState.reqId, "later");
|
|
}
|
|
} else {
|
|
moreJourneys(canvasState.reqId, "earlier");
|
|
}
|
|
}
|
|
|
|
canvasState.dragging = false;
|
|
};
|
|
|
|
const mouseDownHandler = (evt) => {
|
|
let x = evt.x || evt.changedTouches[0].pageX;
|
|
canvasState.dragStartMouse = x;
|
|
canvasState.dragStartOffset = canvasState.offsetX;
|
|
canvasState.dragging = true;
|
|
};
|
|
|
|
const mouseMoveHandler = (evt) => {
|
|
if (canvasState.dragging) {
|
|
evt.preventDefault();
|
|
let x = evt.x || evt.changedTouches[0].pageX;
|
|
canvasState.offsetX = canvasState.dragStartOffset - (canvasState.dragStartMouse - x);
|
|
renderJourneys();
|
|
return true;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('mouseup', mouseUpHandler);
|
|
window.addEventListener('touchend', mouseUpHandler);
|
|
window.addEventListener('mousemove', mouseMoveHandler);
|
|
window.addEventListener('touchmove', mouseMoveHandler);
|
|
window.addEventListener('resize', resizeHandler);
|
|
window.addEventListener('zoom', resizeHandler);
|