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'); }; }