Something went wrong. Try again.
Vibed Google Timeline alternative (VIBED)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283// 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<Theme, { line: string; halo: string }> = { 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<MapLibreMap['setPaintProperty']>
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<GeoPoint, FixProps> { 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<LineString, { gap: boolean }> { const features: Feature<LineString, { gap: boolean }>[] = [] 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 }}