From a2f7b525b2dfb902e18ddf6dff20999db7f43e75 Mon Sep 17 00:00:00 2001 From: MattNotarangelo Date: Thu, 11 Jun 2026 11:41:56 -0700 Subject: [PATCH] feat(viewer): add colour-by-survey-date mode New "Survey date" colour mode: legs are coloured across the model's date range (oldest cool, newest warm; undated legs grey), using the midpoint of each leg's date range. The legend shows the newest/middle/oldest dates as ISO labels, or a "no dates recorded" note when nothing is dated. Pure logic lives in coloring.ts; the dropdown and legend pick the mode up automatically. Co-Authored-By: Claude Fable 5 --- README.md | 6 +-- src/viewer/coloring.ts | 52 ++++++++++++++++++- test/coloring.test.ts | 111 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index abc3e7f..3c0dc8c 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ feature, not a limitation. The app deploys as a pure static site. | Phase | Scope | State | |-------|-------|-------| | **1** | Survex `.3d` (v8): centreline render, orbit/pan/zoom, depth colouring, drag-and-drop, fit-to-view, length/bounds readout, north indicator | ✅ done | -| **1+** | Preset plan/elevation views, orthographic toggle, scale bar, colour modes (elevation / distance-from-entrance / gradient / survey / single), leg-type visibility toggles, PNG export _(ideas adopted from [CaveView.js](https://github.com/aardgoose/CaveView.js))_ | ✅ done | +| **1+** | Preset plan/elevation views, orthographic toggle, scale bar, colour modes (elevation / distance-from-entrance / gradient / survey / date / single), leg-type visibility toggles, PNG export _(ideas adopted from [CaveView.js](https://github.com/aardgoose/CaveView.js))_ | ✅ done | | **2** | **Compass `.plt`** (processed coordinates, LRUD, splays, multi-survey) | ✅ done | | **3** | **Therion `.lox`** + lit triangle-mesh passage walls (the modelled scrap surfaces); **LRUD passage tubes** reconstructed for `.3d`/`.plt` | ✅ done | | **4** | **Interaction & UX**: ViewCube navigation (drag to orbit, click a face to snap); click a station for its details; hover labels + station finder; **measure tool** (straight-line / horizontal / vertical / bearing); **survey-tree show/hide**; **vertical exaggeration**; entrance & fixed-point markers; light/dark theme; metric/imperial units; render-on-demand (idle GPU) | ✅ done | | **5** | **PocketTopo `.top`** (raw DistoX shots: declination, repeated-shot averaging, splays, reference anchoring, trip dates) | ✅ done | -| next | Colour by date; cross-sections from splays; clipping plane / depth cursor; depth fog | planned | +| next | Cross-sections from splays; clipping plane / depth cursor; depth fog | planned | ## Architecture @@ -192,7 +192,7 @@ with the compass directions. - **Quick views**: Plan (top-down, North up — locked to orthographic) and 3D. P = plan. - **Projection**: toggle Perspective ⇄ Orthographic (true-scale); locked to orthographic in plan view. -- **Colour by**: elevation, distance-from-entrance, gradient (steepness), survey/series, or single colour. The legend adapts to the mode. +- **Colour by**: elevation, distance-from-entrance, gradient (steepness), survey/series, survey date (oldest cool → newest warm; undated legs grey), or single colour. The legend adapts to the mode. - **Vertical exaggeration**: a 1×–8× slider to stretch deep caves vertically (no effect in plan). - **Show**: toggle splay / surface / duplicate legs, and the passage-wall mesh (Walls — Therion `.lox` scrap meshes, or tubes reconstructed from LRUD cross-sections for `.3d`/`.plt`). - **Surveys**: a collapsible tree to show/hide individual survey series. diff --git a/src/viewer/coloring.ts b/src/viewer/coloring.ts index 78b02c7..cfb50cd 100644 --- a/src/viewer/coloring.ts +++ b/src/viewer/coloring.ts @@ -6,7 +6,7 @@ import type { CaveModel, Leg } from "../parser/index"; import { depthColor, hslToRgb, type RGB } from "./colormap"; -export type ColorMode = "height" | "distance" | "inclination" | "survey" | "single"; +export type ColorMode = "height" | "distance" | "inclination" | "survey" | "date" | "single"; export interface ColorModeInfo { id: ColorMode; @@ -18,6 +18,7 @@ export const COLOR_MODES: ReadonlyArray = [ { id: "distance", label: "Distance from entrance" }, { id: "inclination", label: "Gradient (steepness)" }, { id: "survey", label: "Survey / series" }, + { id: "date", label: "Survey date" }, { id: "single", label: "Single colour" }, ]; @@ -38,6 +39,9 @@ export interface ColorData { /** Geodesic distance (m) from the nearest entrance, per station id. */ distance?: Float64Array; maxDistance: number; + /** Survey-date range (days since the Unix epoch); absent when nothing is dated. */ + dateMin?: number; + dateMax?: number; } export function prepareColorData(model: CaveModel, mode: ColorMode): ColorData { @@ -47,6 +51,17 @@ export function prepareColorData(model: CaveModel, mode: ColorMode): ColorData { const { distance, max } = entranceDistances(model); return { mode, minZ, maxZ, distance, maxDistance: max }; } + if (mode === "date") { + let dateMin: number | undefined; + let dateMax: number | undefined; + for (const leg of model.legs) { + const d = legDateDay(leg); + if (d === null) continue; + if (dateMin === undefined || d < dateMin) dateMin = d; + if (dateMax === undefined || d > dateMax) dateMax = d; + } + return { mode, minZ, maxZ, maxDistance: 0, dateMin, dateMax }; + } return { mode, minZ, maxZ, maxDistance: 0 }; } @@ -74,6 +89,15 @@ export function legColors(data: ColorData, model: CaveModel, leg: Leg): [RGB, RG const c = leg.survey ? surveyColor(leg.survey) : GREY; return [c, c]; } + case "date": { + const d = legDateDay(leg); + if (d === null || data.dateMin === undefined || data.dateMax === undefined) { + return [GREY, GREY]; + } + const span = data.dateMax - data.dateMin; + const c = depthColor(span > 0 ? (d - data.dateMin) / span : 0.5); + return [c, c]; + } case "single": return [SINGLE, SINGLE]; } @@ -107,11 +131,37 @@ export function legendSpecFor(data: ColorData): LegendSpec { }; case "survey": return { kind: "note", title: "Colour", text: "by survey / series" }; + case "date": + if (data.dateMin === undefined || data.dateMax === undefined) { + return { kind: "note", title: "Survey date", text: "no dates recorded" }; + } + return { + kind: "gradient", + title: "Survey date", + hi: isoOfDay(data.dateMax), + mid: isoOfDay((data.dateMin + data.dateMax) / 2), + lo: isoOfDay(data.dateMin), + }; case "single": return { kind: "hidden" }; } } +const MS_PER_DAY = 86400000; + +/** A leg's date as days since the Unix epoch (midpoint of its range), if dated. */ +function legDateDay(leg: Leg): number | null { + if (!leg.date) return null; + const a = Date.parse(leg.date.from); + const b = Date.parse(leg.date.to); + if (Number.isNaN(a) || Number.isNaN(b)) return null; + return (a + b) / 2 / MS_PER_DAY; +} + +function isoOfDay(day: number): string { + return new Date(Math.round(day * MS_PER_DAY)).toISOString().slice(0, 10); +} + function colorForDistance(d: number, max: number): RGB { if (!Number.isFinite(d)) return GREY; return depthColor(d / max); diff --git a/test/coloring.test.ts b/test/coloring.test.ts index c7b519c..1040877 100644 --- a/test/coloring.test.ts +++ b/test/coloring.test.ts @@ -1,10 +1,24 @@ /** - * Tests for the pure colour-mode logic, focusing on the entrance-distance graph - * (multi-source Dijkstra over the leg network). + * Tests for the pure colour-mode logic: the entrance-distance graph + * (multi-source Dijkstra over the leg network) and the survey-date mode. */ import { describe, it, expect } from "vitest"; -import { parseSurvex3d } from "../src/parser/index"; -import { entranceDistances, surveyColor } from "../src/viewer/coloring"; +import { + parseSurvex3d, + emptyLegFlags, + emptyStationFlags, + type CaveModel, + type DateRange, +} from "../src/parser/index"; +import { + COLOR_MODES, + entranceDistances, + legColors, + legendSpecFor, + prepareColorData, + surveyColor, +} from "../src/viewer/coloring"; +import { depthColor, type RGB } from "../src/viewer/colormap"; import { encode3d, toArrayBuffer } from "./helpers/encode3d"; function parse(bytes: Uint8Array) { @@ -73,3 +87,92 @@ describe("entranceDistances", () => { expect(surveyColor("main")).not.toEqual(surveyColor("branch")); }); }); + +describe("date colour mode", () => { + // Hand-built minimal model: a chain of stations with one leg per date entry. + function datedModel(dates: Array): CaveModel { + const stations: CaveModel["stations"] = []; + for (let i = 0; i <= dates.length; i++) { + const flags = emptyStationFlags(); + flags.underground = true; + stations.push({ id: i, label: `s${i}`, x: i, y: 0, z: 0, flags }); + } + const legs: CaveModel["legs"] = dates.map((date, i) => { + const leg: CaveModel["legs"][number] = { from: i, to: i + 1, flags: emptyLegFlags() }; + if (date) leg.date = date; + return leg; + }); + return { + metadata: { + title: "t", + format: "test", + separator: ".", + bounds: { min: [0, 0, 0], max: [dates.length, 0, 0] }, + isExtendedElevation: false, + }, + stations, + legs, + }; + } + + const day = (iso: string): DateRange => ({ from: iso, to: iso }); + const GREY: RGB = [0.5, 0.5, 0.55]; + + it("colours legs across the date range, oldest cool to newest warm", () => { + const model = datedModel([day("2010-01-01"), day("2015-01-01"), day("2020-01-01"), undefined]); + const data = prepareColorData(model, "date"); + const colors = model.legs.map((l) => legColors(data, model, l)); + expect(colors[0][0]).toEqual(depthColor(0)); // oldest + expect(colors[2][0]).toEqual(depthColor(1)); // newest + expect(colors[1][0]).not.toEqual(colors[0][0]); // mid differs from both ends + expect(colors[1][0]).not.toEqual(colors[2][0]); + expect(colors[0][0]).toEqual(colors[0][1]); // both endpoints share the colour + expect(colors[3][0]).toEqual(GREY); // undated leg + }); + + it("uses the midpoint of a leg's date range", () => { + const model = datedModel([ + { from: "2010-01-01", to: "2010-01-03" }, + day("2010-01-02"), + day("2020-01-01"), // stretch the range so equal midpoints matter + ]); + const data = prepareColorData(model, "date"); + expect(legColors(data, model, model.legs[0])).toEqual(legColors(data, model, model.legs[1])); + }); + + it("produces a gradient legend labelled with ISO dates, newest at the top", () => { + const model = datedModel([day("2010-01-01"), day("2020-01-01")]); + const spec = legendSpecFor(prepareColorData(model, "date")); + expect(spec).toEqual({ + kind: "gradient", + title: "Survey date", + hi: "2020-01-01", + mid: "2015-01-01", // (14610 + 18262) / 2 = 16436 epoch days = exactly 2015-01-01 + lo: "2010-01-01", + }); + }); + + it("shows a note and grey legs when the model has no dates at all", () => { + const model = datedModel([undefined, undefined]); + const data = prepareColorData(model, "date"); + expect(legendSpecFor(data)).toEqual({ + kind: "note", + title: "Survey date", + text: "no dates recorded", + }); + expect(legColors(data, model, model.legs[0])[0]).toEqual(GREY); + }); + + it("handles a single-date survey without dividing by zero", () => { + const model = datedModel([day("2020-01-01"), day("2020-01-01")]); + const data = prepareColorData(model, "date"); + expect(legColors(data, model, model.legs[0])[0]).toEqual(depthColor(0.5)); + const spec = legendSpecFor(data); + expect(spec.kind).toBe("gradient"); + if (spec.kind === "gradient") expect(spec.hi).toBe("2020-01-01"); + }); + + it("is offered in the colour-mode list", () => { + expect(COLOR_MODES.some((m) => m.id === "date")).toBe(true); + }); +});