'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 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) * 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")); 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 / 1.5)); 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 - 1}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);