import { moreJourneys } from './journeysView.js'; import { go } from './router.js'; import { padZeros } from './helpers.js'; import { formatTrainTypes, formatLineDisplayName, formatDateTime } from './formatters.js' import { cachedCoachSequence, coachSequenceCache, coachSequenceCacheKey } from './coach-sequence/index.js'; const colorFor = (leg, type) => { const product = leg.line?.product || 'walk'; return colors[type][product] || colors[type].default; }; const loadFactorColors = { 'low-to-medium': [ '#777', '#ccc', '#ccc' ], 'high': [ '#777', '#777', '#ccc' ], 'very-high': [ '#ee8800', '#ee8800', '#ee8800' ], 'exceptionally-high': [ '#cc3300', '#cc3300', '#cc3300' ], }; const flatten = (arr) => [].concat(...arr); const 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' } }; let rectWidth, padding, rectWidthWithPadding, canvas, ctx; let dpr = window.devicePixelRatio || 1; const canvasState = { offsetX: 0, }; let textCache = {}; const typeTextsFor = leg => { if (!leg.line || !leg.line.name) return []; const [category, number] = leg.line.name.split(" "); const key = coachSequenceCacheKey(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture); if (!key) return []; const info = coachSequenceCache[key]; if (!info || info instanceof Promise) { return []; } return formatTrainTypes(info).split(" + "); }; export const setupCanvas = (data, isUpdate) => { if (!isUpdate) canvasState.offsetX = (window.innerWidth / dpr) > 600 ? 140 : 80; canvas = document.getElementById('canvas'); ctx = canvas.getContext('2d'); if (data) { canvasState.data = { ...data, journeys: Object.keys(data.journeys).sort((a, b) => Number(a) - Number(b)).map(k => data.journeys[k]) }; (async () => { for (const journey of canvasState.data.journeys) { for (const leg of journey.legs) { if (!leg.line) continue; const [category, number] = leg.line.name.split(" "); if (data.profile === "db") await cachedCoachSequence(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture); setupCanvas(null, true); } } }) (); } canvas.addEventListener('mousedown', mouseDownHandler, {passive: true}); canvas.addEventListener('touchstart', mouseDownHandler, {passive: 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); resizeHandler(); return { unload: () => { canvas.removeEventListener('mousedown', mouseDownHandler); canvas.removeEventListener('touchstart', mouseDownHandler); window.removeEventListener('mouseup', mouseUpHandler); window.removeEventListener('touchend', mouseUpHandler); window.removeEventListener('mousemove', mouseMoveHandler); window.removeEventListener('touchmove', mouseMoveHandler); window.removeEventListener('resize', resizeHandler); window.removeEventListener('zoom', resizeHandler); }, }; }; const getTextCache = (text, color, fixedHeight) => { const index = `${text}|${color}|${rectWidth}|${dpr}|${fixedHeight}`; if (!textCache[index]) { textCache[index] = makeTextCache(text, color, fixedHeight); } return textCache[index]; }; const makeTextCache = (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; ctx.fillText(text, 0, height); return canvas; }; let lastAnimationUpdate = 0, firstDeparture = 0, scaleFactor = 0, lastArrival = 0; let animationInterval; const renderJourneys = () => { ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); let x = canvasState.offsetX - canvasState.data.indexOffset * rectWidthWithPadding, y; const firstVisibleJourney = Math.max(0, Math.floor((-x + padding) / rectWidthWithPadding)); const numVisibleJourneys = Math.ceil(canvas.width / dpr / rectWidthWithPadding); const visibleJourneys = canvasState.data.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) * (canvas.height - 64 * dpr) / dpr; const now = new Date(); const factor = Math.min(.3, (now - lastAnimationUpdate) / 20); if (!lastAnimationUpdate) { firstDeparture = Number(targetFirstDeparture); lastArrival = Number(targetLastArrival); scaleFactor = targetScaleFactor; } else { firstDeparture = firstDeparture + (targetFirstDeparture - firstDeparture) * factor; lastArrival = lastArrival + (targetLastArrival - lastArrival) * factor; scaleFactor = scaleFactor + (targetScaleFactor - scaleFactor) * factor; } lastAnimationUpdate = now; if (Math.abs(scaleFactor - targetScaleFactor) > 1 || Math.abs(firstDeparture - targetFirstDeparture) > 1 || Math.abs(lastArrival - targetLastArrival) > 1 ) { if (!animationInterval) animationInterval = setInterval(() => renderJourneys(), 16.6); } else { if (animationInterval) { clearInterval(animationInterval); animationInterval = null; } } let time = canvasState.data.journeys[0].legs[0].plannedDeparture; ctx.font = `${(window.innerWidth / dpr) > 600 ? 20 : 15}px sans-serif`; ctx.fillStyle = '#aaa'; while (time < lastArrival) { const y = (time - firstDeparture) * scaleFactor + 32; ctx.fillText(formatDateTime(time, 'time'), (window.innerWidth / dpr) > 600 ? 30 : 10, y); ctx.fillRect(0, y, canvas.width / dpr, 1); time = new Date(Number(time) + 3600000);//Math.floor(120/scaleFactor)); } ctx.fillStyle = '#fa5'; y = (new Date() - 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'); if (canvasState.data.earlierRef) { 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 (const journey of canvasState.data.journeys) { journey.legs.reverse(); for (const leg of journey.legs) { if (Math.abs(leg.departureDelay) > 60 || Math.abs(leg.arrivalDelay) > 60) { const duration = (leg.plannedArrival - leg.plannedDeparture) * scaleFactor; y = (leg.plannedDeparture - 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 - canvasState.data.indexOffset * rectWidthWithPadding; for (const journey of canvasState.data.journeys) { let xOffset = 0; let nextLeg; for (const leg of journey.legs) { if (nextLeg && nextLeg.departure < leg.arrival) { xOffset -= 5; } x += xOffset; const duration = ((leg.arrival || leg.plannedArrival) - (leg.departure || leg.plannedDeparture)) * scaleFactor; y = ((leg.departure || leg.plannedDeparture) - firstDeparture) * scaleFactor + 32; ctx.shadowColor = '#00000060'; //ctx.shadowBlur = 5; if (leg.walking || leg.transfer) { 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.strokeStyle = colorFor(leg, 'text'); ctx.strokeRect(x, y, rectWidth, duration); } //ctx.shadowBlur = 0; let preRenderedText = getTextCache(formatLineDisplayName(leg.line), colorFor(leg, 'text')); let offset = duration / 2; if ((offset + preRenderedText.height / dpr) < duration - 5) { ctx.scale(1 / dpr, 1 / dpr); ctx.drawImage(preRenderedText, dpr * (x + 5), Math.floor(dpr * (y + offset) - preRenderedText.height / 2.3)); ctx.scale(dpr, dpr); offset += preRenderedText.height / dpr / 1.3 + 5; } const typeTexts = typeTextsFor(leg); for (const typeText of typeTexts) { const preRenderedTypeText = getTextCache(typeText, '#555'); if ((offset + preRenderedText.height / dpr) < duration - 5) { ctx.scale(1 / dpr, 1 / dpr); ctx.drawImage(preRenderedTypeText, dpr * (x + 5), Math.floor(dpr * (y + offset) - preRenderedTypeText.height / 2.3)); ctx.scale(dpr, dpr); offset += preRenderedText.height / dpr / 1.3; } } if (leg.cancelled) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + rectWidth, y + duration); ctx.strokeStyle = colorFor(leg, 'cancelFill'); ctx.lineWidth = 5; ctx.stroke(); ctx.lineWidth = 1; } /* draw journey start and end time */ let 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]); for (const [time, y] of times) { preRenderedText = getTextCache(formatDateTime(time, 'time'), '#fff', 15); ctx.scale(1 / dpr, 1 / dpr); ctx.drawImage(preRenderedText, Math.ceil(dpr * (x + ((rectWidth - preRenderedText.width/dpr)) / 2)), dpr * (y - 7.5)); ctx.scale(dpr, dpr); } if (leg.loadFactor && duration > 20) { ctx.shadowColor = '#00000090'; //ctx.shadowBlur = 2; [ "#777", "#aaa", "#aaa" ]; for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.fillStyle = loadFactorColors[leg.loadFactor][i]; ctx.arc(x + (i + 3) * rectWidth / 8, y + duration - 9.5, 5, 0, 2 * Math.PI, false); ctx.fill(); } //ctx.shadowBlur = 0; } x -= xOffset; nextLeg = leg; } journey.legs.reverse(); x += rectWidthWithPadding; } if (canvasState.data.laterRef) { 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; 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); lastAnimationUpdate = 0; renderJourneys(); }; const mouseUpHandler = (evt) => { const x = evt.x || evt.changedTouches[0].pageX; if (canvasState.dragging && canvasState.isClick) { evt.preventDefault(); const num = Math.floor((x - canvasState.offsetX + 2 * padding) / rectWidthWithPadding) + canvasState.data.indexOffset; if (num >= 0) { if (num < canvasState.data.journeys.length) { const j = canvasState.data.journeys[num]; go(`/j/${canvasState.data.profile || "db"}/${j.refreshToken}`); } else if (canvasState.data.laterRef) { moreJourneys(canvasState.data.slug, 'later'); } } else if (canvasState.data.earlierRef) { moreJourneys(canvasState.data.slug, 'earlier'); } } canvasState.dragging = false; canvasState.isClick = false; }; const mouseDownHandler = (evt) => { const x = evt.x || evt.changedTouches[0].pageX; canvasState.dragStartMouse = x; canvasState.dragStartOffset = canvasState.offsetX; canvasState.dragging = true; canvasState.isClick = true; }; const mouseMoveHandler = (evt) => { if (canvasState.dragging) { evt.preventDefault(); const x = evt.x || evt.changedTouches[0].pageX; canvasState.offsetX = canvasState.dragStartOffset - (canvasState.dragStartMouse - x); if (Math.abs(canvasState.dragStartMouse - x) > 20) canvasState.isClick = false; renderJourneys(); return true; } };