Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -192,7 +192,7 @@ with the compass directions.

- **Quick views**: Plan (top-down, North up — locked to orthographic) and 3D. <kbd>P</kbd> = 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.
Expand Down
52 changes: 51 additions & 1 deletion src/viewer/coloring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +18,7 @@ export const COLOR_MODES: ReadonlyArray<ColorModeInfo> = [
{ 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" },
];

Expand All @@ -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 {
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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);
Expand Down
111 changes: 107 additions & 4 deletions test/coloring.test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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<DateRange | undefined>): 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);
});
});
Loading