// Map layers for a day's fixes and track, the data they draw, and the emphasis of a time range. // // A range is emphasized by dimming the base layers with constant paint values and drawing the // fixes and track inside it again, from small sources, on top. Data-driven paint or layout changes // would make MapLibre re-tile the whole day on every selection change. import type { Feature, FeatureCollection, LineString, Point as GeoPoint, Position } from 'geojson' import type { LayerSpecification, MapLibreMap } from 'maplibre-gl' import { MAX_SPACING, type TimeRange } from './analysis' import type { Point } from './api' import { MOCK_COLOR, originColorExpression } from './colors' import type { Theme } from './theme.svelte' // MapLibre's expression types are strict tuples; expressions here are built dynamically. type Expr = any export const SOURCES = { others: 'others', track: 'track', points: 'points', stays: 'stays', // The fixes and track inside the emphasized range. emphasisTrack: 'emphasis-track', emphasisPoints: 'emphasis-points', } as const /** Layers drawing fixes, topmost first, so pointer handlers pick an emphasized fix over a dimmed one. */ export const FIX_LAYERS = ['fixes-emphasis', 'fixes'] /** Tracks of the devices not shown: thin, in a colour no fix origin uses. */ export const OTHERS_COLOR = '#0097a7' /** Track line and marker outlines contrast with the basemap; fix colors stay the same. */ const INK: Record = { light: { line: '#202124', halo: '#fff' }, dark: { line: '#e8eaed', halo: '#202124' }, } /** Fix dot radius in px at two zoom levels, interpolated linearly between and clamped outside. */ const FIX_RADIUS = { zoom: [8, 16], px: [2.5, 5] } as const /** Outline width of fixes in use; set-aside fixes are drawn hollow with a wider outline. */ const FIX_STROKE = { used: 1, unused: 1.5 } /** Radius in px of a fix dot (without its outline) at a zoom level. */ export function fixRadius(zoom: number): number { const [z0, z1] = FIX_RADIUS.zoom const [r0, r1] = FIX_RADIUS.px const f = Math.min(1, Math.max(0, (zoom - z0) / (z1 - z0))) return r0 + (r1 - r0) * f } /** * Whether the fill of a fix in use hides a circle of radius r px around it, 1 px outline and * antialiasing included: the circle would only tint the fill with its own color. */ export function hiddenByFix(zoom: number, r: number): boolean { return r + 1 <= fixRadius(zoom) } /** Opacity of the base layers with and without an emphasized range; the emphasis layers use `plain`. */ const OPACITY = { plain: { fix: 1, mock: 1, track: 0.9, gap: 0.8 }, dimmed: { fix: 0.15, mock: 0.2, track: 0.12, gap: 0.12 }, } /** Track width with and without an emphasized range. */ const TRACK_WIDTH = { plain: 2.5, dimmed: 1.5 } /** Dimming switches instantly, like the emphasis layers appearing. */ const NOW = { duration: 0, delay: 0 } export function dayLayers(theme: Theme): LayerSpecification[] { const color: Expr = originColorExpression() const used: Expr = ['get', 'used'] const lit: Expr = ['boolean', ['feature-state', 'lit'], false] const ink = INK[theme] const o = OPACITY.plain const [z0, z1] = FIX_RADIUS.zoom const [r0, r1] = FIX_RADIUS.px const radius: Expr = ['interpolate', ['linear'], ['zoom'], z0, r0, z1, r1] const gap = (id: string, source: string): LayerSpecification => ({ id, type: 'line', source, filter: ['==', ['get', 'gap'], true], paint: { 'line-color': MOCK_COLOR, 'line-width': 1.5, 'line-dasharray': [2, 2], 'line-opacity': o.gap, 'line-opacity-transition': NOW, }, }) const track = (id: string, source: string): LayerSpecification => ({ id, type: 'line', source, filter: ['==', ['get', 'gap'], false], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ink.line, 'line-width': TRACK_WIDTH.plain, 'line-opacity': o.track, 'line-width-transition': NOW, 'line-opacity-transition': NOW, }, }) const mock = (id: string, source: string): LayerSpecification => ({ id, type: 'circle', source, filter: ['==', ['get', 'mock'], true], paint: { 'circle-radius': 8, 'circle-opacity': 0, 'circle-stroke-color': MOCK_COLOR, 'circle-stroke-width': 2, 'circle-stroke-opacity': o.mock, 'circle-stroke-opacity-transition': NOW, }, }) // Fixes set aside by classification are drawn hollow. const fixes = (id: string, source: string): LayerSpecification => ({ id, type: 'circle', source, paint: { 'circle-radius': radius, 'circle-color': ['case', used, color, ink.halo], 'circle-stroke-color': ['case', used, ink.halo, color], 'circle-stroke-width': ['case', used, FIX_STROKE.used, FIX_STROKE.unused], 'circle-opacity': o.fix, 'circle-stroke-opacity': o.fix, 'circle-opacity-transition': NOW, 'circle-stroke-opacity-transition': NOW, }, }) return [ { id: 'others-track', type: 'line', source: SOURCES.others, layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': OTHERS_COLOR, 'line-width': 1.25, 'line-opacity': 0.85 }, }, gap('track-gap', SOURCES.track), track('track', SOURCES.track), gap('track-gap-emphasis', SOURCES.emphasisTrack), track('track-emphasis', SOURCES.emphasisTrack), // Stays of the timeline listed in the sidebar: a disc where it places each one, under the fixes. // The entry hovered in the list sets its disc's `lit` feature state. { id: 'stays', type: 'circle', source: SOURCES.stays, paint: { 'circle-radius': ['case', ['get', 'nested'], 8, 14], 'circle-color': ink.line, 'circle-opacity': ['case', lit, 0.4, 0.18], 'circle-stroke-color': ink.line, 'circle-stroke-width': ['case', lit, 2.5, 1], 'circle-stroke-opacity': ['case', lit, 1, 0.6], }, }, mock('mock', SOURCES.points), mock('mock-emphasis', SOURCES.emphasisPoints), fixes('fixes', SOURCES.points), fixes('fixes-emphasis', SOURCES.emphasisPoints), ] } type PaintArgs = Parameters export interface PaintOverride { layer: string property: PaintArgs[1] value: number } /** Constant paint values of the base layers: dimmed while a range is emphasized above them. */ export function selectionStyle(dimmed: boolean): PaintOverride[] { const o = dimmed ? OPACITY.dimmed : OPACITY.plain return [ { layer: 'fixes', property: 'circle-opacity', value: o.fix }, { layer: 'fixes', property: 'circle-stroke-opacity', value: o.fix }, { layer: 'mock', property: 'circle-stroke-opacity', value: o.mock }, { layer: 'track', property: 'line-opacity', value: o.track }, { layer: 'track', property: 'line-width', value: dimmed ? TRACK_WIDTH.dimmed : TRACK_WIDTH.plain }, { layer: 'track-gap', property: 'line-opacity', value: o.gap }, ] } /** Index of the first point at or after t, in points sorted by time. */ function firstAtOrAfter(points: { t: number }[], t: number): number { let lo = 0 let hi = points.length while (lo < hi) { const mid = (lo + hi) >> 1 if (points[mid].t < t) lo = mid + 1 else hi = mid } return lo } /** Index of the first point after t, in points sorted by time. */ function firstAfter(points: { t: number }[], t: number): number { let lo = 0 let hi = points.length while (lo < hi) { const mid = (lo + hi) >> 1 if (points[mid].t <= t) lo = mid + 1 else hi = mid } return lo } /** Bounds [start, end) of the points inside the range, in points sorted by time. */ export function rangeSlice(points: { t: number }[], r: TimeRange): [number, number] { const start = firstAtOrAfter(points, r.start) return [start, Math.max(start, firstAfter(points, r.end))] } /** * The span whose fixes and track are emphasized for a range: the range itself, or, when it holds no * fix, the range widened to the fixes on either side, so the track across it stands out. */ export function emphasisRange(points: { t: number }[], r: TimeRange): TimeRange { const lo = firstAtOrAfter(points, r.start) if (lo < points.length && points[lo].t <= r.end) return r return { start: lo > 0 ? points[lo - 1].t : r.start, end: lo < points.length ? points[lo].t : r.end, } } export interface FixProps { id: number t: number origin: string mock: boolean used: boolean } export function pointFeatures(points: Point[]): FeatureCollection { return { type: 'FeatureCollection', features: points.map((p) => ({ type: 'Feature', id: p.id, geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, properties: { id: p.id, t: p.t, origin: p.origin, mock: !!p.mock, used: p.status === 'used' }, })), } } /** * The track through time-ordered fixes: one line per run of consecutive fixes, and each gap (fixes * further apart than the moving capture interval) as its own line, so its dashes start at its ends. */ export function trackFeatures(points: { t: number; lat: number; lon: number }[]): FeatureCollection { const features: Feature[] = [] const line = (coordinates: Position[], gap: boolean) => features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates }, properties: { gap } }) let run: Position[] | null = null for (let i = 1; i < points.length; i++) { const a = points[i - 1] const b = points[i] if (b.t - a.t > MAX_SPACING.moving) { run = null line( [ [a.lon, a.lat], [b.lon, b.lat], ], true, ) } else { if (!run) line((run = [[a.lon, a.lat]]), false) run.push([b.lon, b.lat]) } } return { type: 'FeatureCollection', features } }