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