From 262473544577be155caf92036767eef4d36ce66b Mon Sep 17 00:00:00 2001 From: Marcus Kempe Date: Thu, 24 Sep 2026 20:07:17 +0200 Subject: [PATCH] Bring control descriptions to full IOF 2018/2024 coverage Data model and sheet - ControlDescription gains e (appearance / second feature) and h (other information); dimensions move from column E to F as the spec requires. One shared descriptionCells() converter replaces the two duplicated OCAD->IOF converters. - courses.description_instructions (JSONB, new migration) holds special-instruction rows (13.x) and the finish variant (14.x). - Shared buildDescriptionSheet() row model: 3-row IOF header (event / classes / course . length . climb), start row, controls, specials, finish with the measured last-control -> finish distance. Used by the on-map sheet in the course editor, the print block and the layout editor preview so screen and print are identical. - IOF sheet rules: bold header with thick cell rules, thick line under start, after every third description, on either side of a special instruction and above the finish; thick verticals after columns C and F (A B C | D E F | G H); alternate row shading. Editor - Reworked ControlDescriptionEditor: labelled 3x3 compass grid for column C, visible names under every symbol (mobile), E/H sections, live summary sentence, bottom sheet on narrow screens. en/sv. - Crosshair cursor in editor mode and a magnifier loupe while dragging (MapLoupe over passive TileLayers sharing the blob cache). - Context-menu actions are disabled instead of silently ignored while a save is in flight or the control list has not loaded. Autodetect - New passes: crossing/junction (F, with the second feature in E, also path . path . junction), bend, free end, which-of-similar (C), side-of / outside corner for compact areas, edge / part / inside and outside corner for extended areas. - OCAD Bezier segments are flattened onto the drawn curve; junction snap tolerance 0.3 mm; within 1.5 mm the junction replaces the plain line candidate. - Extended areas rank by depth-from-edge + 0.5 mm so a boulder beside the control beats the open land it stands in. - autoDescribe fills the top candidate on create; on move it replaces an untouched auto description (never a hand-edited one). The menu offers a single replacement suggestion only when it differs. Also fixes a flaky runner-count read in structured-search-names.spec. Co-authored-by: Cursor --- docs/bugfix-control-description-columns.md | 79 +++ ...ontrol-descriptions-and-editor-geometry.md | 36 +- docs/course-editor.md | 156 ++--- docs/course-maps.md | 15 +- docs/features.md | 2 +- e2e/course-editor.spec.ts | 167 +++-- e2e/structured-search-names.spec.ts | 8 +- .../migration.sql | 3 + packages/api/prisma/schema.prisma | 5 +- .../src/__tests__/course-map-layout.test.ts | 55 +- .../__tests__/description-autodetect.test.ts | 151 +++++ .../src/__tests__/event-map-objects.test.ts | 37 ++ .../__tests__/integration/course-maps.test.ts | 22 +- .../description-autodetect.test.ts | 69 +++ packages/api/src/course-maps/map-page-svg.ts | 4 + .../api/src/course-maps/resolve-layout.ts | 93 ++- packages/api/src/course-maps/routes.ts | 2 + packages/api/src/description-autodetect.ts | 577 ++++++++++++++++-- packages/api/src/event-map-objects.ts | 93 ++- packages/api/src/ocd-course-parser.ts | 2 + packages/api/src/routers/control.ts | 120 +++- packages/api/src/routers/course.ts | 64 +- packages/api/src/routers/courseMap.ts | 20 +- packages/api/src/routers/mapTemplate.ts | 1 + .../shared/src/__tests__/course-maps.test.ts | 57 +- .../src/__tests__/description-rows.test.ts | 182 ++++++ .../src/course-maps/description-rows.ts | 205 +++++++ .../shared/src/course-maps/description-svg.ts | 246 +++++--- packages/shared/src/course-maps/index.ts | 1 + .../shared/src/course-maps/iof-symbol-meta.ts | 30 +- .../shared/src/course-maps/iof-symbols.ts | 141 ++++- packages/shared/src/course-maps/validate.ts | 3 + packages/shared/src/types.ts | 50 +- .../components/ControlDescriptionEditor.tsx | 244 ++++++-- .../web/src/components/MapLayoutEditor.tsx | 18 +- packages/web/src/components/MapLoupe.tsx | 143 +++++ packages/web/src/components/MapPanel.tsx | 10 + packages/web/src/components/MapViewer.tsx | 410 ++++++++++--- packages/web/src/components/TileLayer.tsx | 11 +- .../web/src/i18n/locales/en/controls.json | 11 +- packages/web/src/i18n/locales/en/courses.json | 7 +- .../web/src/i18n/locales/sv/controls.json | 11 +- packages/web/src/i18n/locales/sv/courses.json | 7 +- packages/web/src/iof-symbols.ts | 6 + .../control-description-options.test.ts | 59 +- .../src/lib/control-description-options.ts | 78 ++- packages/web/src/lib/course-map-types.ts | 4 + .../__tests__/new-anchors.test.ts | 6 +- packages/web/src/pages/CourseEditorPage.tsx | 170 +++++- packages/web/src/pages/MapTemplatesPage.tsx | 1 + packages/web/src/pages/MapsPage.tsx | 1 + scripts/generate-iof-symbol-meta.mjs | 18 +- 52 files changed, 3388 insertions(+), 523 deletions(-) create mode 100644 docs/bugfix-control-description-columns.md create mode 100644 packages/api/prisma/migrations/20260924180000_course_description_instructions/migration.sql create mode 100644 packages/shared/src/__tests__/description-rows.test.ts create mode 100644 packages/shared/src/course-maps/description-rows.ts create mode 100644 packages/web/src/components/MapLoupe.tsx diff --git a/docs/bugfix-control-description-columns.md b/docs/bugfix-control-description-columns.md new file mode 100644 index 0000000..e52425a --- /dev/null +++ b/docs/bugfix-control-description-columns.md @@ -0,0 +1,79 @@ +# Bugfix: Control description columns and column-C picker + +## What was wrong + +1. **Column layout drifted from the IOF 2018/2024 sheet.** Free-text + dimensions (`s`) were drawn in column E with an `"m"` suffix. The + spec puts appearance (8.x) / second feature in E and dimensions in F + (plain number, no unit). Column H (other information, 12.x) was + always empty even though the SVG glyphs existed. + +2. **Column C ("which of similar features") was effectively unusable.** + The picker offered eight unlabeled arrow icons identical in look to + column G "side of" glyphs, with names only in hover `title=` + tooltips — invisible on touch devices. Selecting Northern vs + North-eastern was guesswork, especially on mobile. + +3. **Autodetect never filled C, F (crossing/junction/bend), or E + (second feature)** — by design in v1, but path crossings are the + most common missed suggestion. + +4. **The three-candidate suggestion menu was almost never useful** — + the top hit is the one users want. Fresh placements still required a + click to apply it. + +## Fix + +- Extended `ControlDescription` with `e` and `h`; shared + `descriptionCells()` places size in F and appearance/second feature + in E. +- Reworked `ControlDescriptionEditor`: labelled 3×3 compass for C, + visible names under every option, live summary sentence, E/H + sections, mobile bottom sheet. +- Autodetect now detects crossings, junctions, bends, free ends, and + which-of-similar; `autoDescribe` on create fills the top candidate + automatically. After a move of an already-described control the menu + offers a single replacement if it differs. +- Crosshair cursor in editor mode + magnifier loupe while dragging. +- Course-level special/finish instruction rows (`description_instructions`). + +## Follow-up (same day) + +Field testing on a real club map surfaced four more problems: + +1. **Finish row had no distance.** Only an explicit + `finish.lengthM` was drawn. The sheet now measures the last control + → finish leg from the control positions and the map scale + (`buildDescriptionSheet({ finishLengthM })`); an explicit length + still wins. +2. **Header looked wrong.** Grey-shaded header rows and no visual + grouping. The sheet now follows the IOF layout rules: plain white, + bold header text, thick rules around every header cell, a thick rule + under the start row, "after every third description and on either + side of any special instruction", and above the finish row; thick + verticals after columns C and F (A B C | D E F | G H); thin rules + elsewhere; alternate row shading kept (`hasThickRuleBelow`, + `DESCRIPTION_THICK_COLUMNS`). The print block is the same model: + `resolve-layout.ts` now feeds `renderDescriptionBlockSvg` the full + sheet (header, start, specials, finish + measured finish distance) + instead of bare control rows under a title. +3. **Header row 1 showed the course name** — the event name was never + passed down. `MapPanel` now reads it from the dashboard query and + hands it to `MapViewer` as `eventName`. +4. **Autodetect missed obvious things** on real maps: + - *Side of boulder* was lost because the control was also *inside* + open land, and "inside" ranked as distance 0. Extended areas now + rank by depth-from-edge + 0.5 mm. + - *Path junctions* were missed because (a) OCAD Bezier handles were + used as vertices, so curved paths never met within tolerance, + (b) the snap tolerance was 0.15 mm, (c) the junction only won when + the control was nearer the intersection than the line itself. + Beziers are now flattened, tolerance is 0.3 mm, and within 1.5 mm + the junction replaces the plain line candidate. + - Moving an auto-described control kept the old side. Moves now + re-describe when the description is the untouched autodetect + result. + - New: edge / part / corner of extended areas, outside corner of + buildings. + +See `docs/course-editor.md` and `docs/control-descriptions-and-editor-geometry.md`. diff --git a/docs/control-descriptions-and-editor-geometry.md b/docs/control-descriptions-and-editor-geometry.md index e0182cb..5d54afe 100644 --- a/docs/control-descriptions-and-editor-geometry.md +++ b/docs/control-descriptions-and-editor-geometry.md @@ -29,16 +29,38 @@ what the parser has always produced (`ControlDescription` in ```jsonc { - "c": "0.208", // Column C: which of similar features - "d": "2.001", // Column D: control feature (e.g. Terrace) - "g": "11.143", // Column G: location of the flag (e.g. NE side) - "s": "1,5", // Column E: dimensions text (1.5 m) - "f": "10.1" // Column F: combination / second feature + "c": "0.201", // Column C: which of similar (Northern) + "d": "2.004", // Column D: control feature (Boulder) + "e": "8.001", // Column E: appearance (Low) — or a second D-code for crossings + "s": "1,5", // Dimensions text — drawn in column F when no combination + "f": "10.001", // Column F: combination (crossing / junction / bend) + "g": "11.101", // Column G: location of the flag (N side) + "h": "12.001" // Column H: other information (First aid) } ``` -The web `iof-symbols.ts` module maps these codes to IOF symbol SVGs per -sheet column, exactly as before. +Shared `descriptionCells()` in `iof-symbols.ts` is the single converter +used by the on-map sheet, the print block, and the editor preview. + +### Course-level instructions + +`courses.description_instructions` (JSONB, migration +`20260924180000_course_description_instructions`) holds special rows and +the finish variant: + +```jsonc +{ + "specials": [ + { "afterControlId": null, "kind": "13.1", "lengthM": 60 }, + { "afterControlId": 31, "kind": "13.2", "lengthM": 120 } + ], + "finish": { "kind": "14.1", "lengthM": 150 } +} +``` + +`afterControlId` is the public control id; `null` means after start. +Specials whose control leaves the sequence are pruned on +`course.update`. ### Migration + backfill diff --git a/docs/course-editor.md b/docs/course-editor.md index a8cac51..057a4d8 100644 --- a/docs/course-editor.md +++ b/docs/course-editor.md @@ -119,92 +119,96 @@ and clicks pass through everywhere the panel isn't. **Edit description** on a selected control opens a modal IOF description editor (`components/ControlDescriptionEditor.tsx`): symbol -grids per sheet column — C (which of similar, 11 symbols), D (control -feature, 73 symbols grouped landforms → special items), F (combination, -3) and G (flag location, 8 feature parts × 8 directions + 6 -non-directional) — plus a free-text dimensions field (column E) and a -live preview row. Saving issues `control.update { description }` -(`null` when everything is cleared), which is undoable like any other -edit. Descriptions live on the control row (see -`control-descriptions-and-editor-geometry.md`), so they follow the -control across courses. - -The stored encoding is OCAD course-setting text codes; the SVG symbols -are keyed by IOF number. `lib/control-description-options.ts` pairs -every pickable symbol with a canonical OCAD code chosen so the -untouched converters in `iof-symbols.ts` map it straight back — the -round-trip is asserted for all 157 options in -`lib/__tests__/control-description-options.test.ts`. Symbol tooltips -come from `iof-symbol-meta.ts` (generated by +grids per sheet column — C (which of similar, labelled compass grid + +upper/lower/middle), D (control feature, grouped landforms → special +items), E (appearance 8.x or a second feature for crossings/junctions), +F (dimensions free-text + crossing/junction/bend), G (flag location), +and H (other info 12.x) — with written symbol titles (mobile-friendly), +a live preview row, and a composed summary sentence. Saving issues +`control.update { description }` (`null` when everything is cleared), +which is undoable like any other edit. Descriptions live on the control +row (see `control-descriptions-and-editor-geometry.md`), so they follow +the control across courses. + +The stored encoding is OCAD course-setting text codes (`{c,d,e,s,f,g,h}`); +the SVG symbols are keyed by IOF number. Shared `descriptionCells()` maps +those fields onto sheet columns C–H (dimensions plain text in F, no `m` +suffix). `lib/control-description-options.ts` pairs every pickable symbol +with a canonical OCAD code so the converters round-trip — asserted in +`lib/__tests__/control-description-options.test.ts`. Symbol names come +from `iof-symbol-meta.ts` (generated by `scripts/generate-iof-symbol-meta.mjs` from the svg-control-descriptions -package, which carries Purple Pen's symbol names) with complete English -and Swedish coverage; the few IOF 2018 symbols Purple Pen lacks Swedish -for are patched in the generator. Fields the user does not touch keep -whatever encoding the OCD importer stored — several OCAD codes can mean -the same symbol, and both render identically. +package) with complete English and Swedish coverage. Fields the user does +not touch keep whatever encoding the OCD importer stored — several OCAD +codes can mean the same symbol, and both render identically. ### Description autodetect -When a placed control has **no description yet** — which is every -freshly placed one, and the reducer auto-selects those — the context -menu grows a *Suggested description* block: what the base map says the -control sits on, one click to apply. The applied description is a -normal undoable `control.update { description }`, so `Ctrl+Z` takes it -back off. A **drag re-opens the question**: the just-moved control gets -suggestions even when it already has a description (the old one -described the old spot); applying one, or saving via the modal editor, -settles it again (`lastMovedId` in `CourseEditorPage`). +Placing a control with `autoDescribe: true` fills the top map-based +suggestion in the same `control.create` write — no menu click needed. +The editor always sends `autoDescribe: true` on a **move** too; the +server then re-describes the control *only if* its current description +is exactly what the autodetect produced for the old position (i.e. the +user never touched it). "N side of boulder" therefore follows the +control to "W side of boulder", and a control dragged into featureless +terrain loses its stale auto description. A hand-edited description is +never overwritten — for those the context menu may offer a single +*Suggested: …* row if the new spot's top candidate differs; applying it +is a normal undoable `control.update`. Undo of a move restores both +position and the previous description. The pipeline: ``` -control.suggestDescription { x, y } (paper mm, eventProcedure) - → loadEventMapObjects(db, eventId) event-map-objects.ts (cache) - → suggestDescriptions(objects, x, y) description-autodetect.ts (pure) - → [{ d, g?, isom, distanceMm }] ≤ 3 +control.create / update { autoDescribe, xpos, ypos } + → loadEventMapObjects → suggestDescriptions → store top candidate + (update: only when no description, or old description == + suggestDescriptions(old position)) + +control.suggestDescription { x, y } (menu after move) + → [{ d, c?, e?, f?, g?, isom, distanceMm }] ≤ 3 ``` -- **`event-map-objects.ts`** — the tile pipeline discards the parsed - `OcadFile` after rasterising, so this module keeps its own cache, - modelled on `event-crs.ts`: per event, invalidated when a newer - `map_files.uploaded_at` appears. It stores only what search needs - (`sym`, `objType`, coordinates, bbox) and only for symbols the mapping - table knows, which drops contours, course overprint and text — the - bulk of a real map. -- **`@oxygen/shared`'s `isom-description-map.ts`** — ISOM 2017-2 symbol - number → `{ d, kind, g? }`, keyed by `Math.floor(sym / 1000)` (OCAD - encodes the symbol as `isom × 1000 + variant`, so 204000 → 204 → - boulder → `"2.004"`). `d` values are the same canonical OCAD codes - `control-description-options.ts` uses, and `g: true` marks features - with a definite centre, where a side-of suggestion means something — - "N side of the boulder" yes, "N side of the path" no. The table lives - in `shared` so the web test - (`lib/__tests__/isom-description-map.test.ts`) can resolve every entry - through the real OCAD→IOF converters and the symbol library: a typo - like `"2.04"` fails loudly instead of rendering an empty cell. -- **`description-autodetect.ts`** — bbox prefilter (expanded by the - radius), then exact distance: euclidean for points, min - point-to-segment for lines, 0 inside the ring for areas (ray cast) and - distance to the boundary outside it. Default radius 3 mm ≈ one control - circle. Results are deduped per **column-D code** — several ISOM - symbols share one (footpath 505 and vehicle track 504 are both - 5.002), and the user picks a description, not a map symbol — sorted - nearest-first, capped at 3. - Column G comes from the compass bearing feature → control, snapped to - 8 directions and emitted as `11.101`…`11.108`; paper Y points north, - hence `atan2(dx, dy)`. Controls within 0.3 mm of the feature count as - *on* it and get no direction. - -Labels and symbol SVG are resolved by the page (`iofSymbolName`, -`IOF_SYMBOLS`) and handed to the viewer ready-made as -`editor.suggestions`, the same contract as `moveWarnings`: the viewer -knows where the menu goes, not what a boulder is called in Swedish. - -**Deferred on purpose**: column C (which of several), column F -(junction / crossing), between-features, column E dimensions, text and -rectangle object types, and refined G for lines (side-of vs on-line). -Those need either information the map does not carry or guesses, and a -missing suggestion is cheaper than a wrong one. +- **`event-map-objects.ts`** — per-event cache of slim OCAD objects + (invalidated on map upload), filtered to symbols in + `ISOM_DESCRIPTION_MAP`. Bezier segments are flattened onto the drawn + curve (8 steps per cubic) — with the raw handles kept as vertices, a + curved path whose end sits on another path would miss the junction + tolerance by a millimetre. +- **`isom-description-map.ts`** — ISOM → `{ d, kind, g? }` with + canonical OCAD codes. +- **`description-autodetect.ts`** — nearest feature per column-D code, + plus geometric enrichments: + - **Crossing / junction** (`f=10.001` / `10.002`) when two lines meet + within 1.5 mm of the control; a line ending up to 0.3 mm short of + (or past) the other still counts as a junction. The nearer line goes + in `d`, the other in `e` — also when both are the same kind, since + the IOF sheet writes "path · path · junction". Within that radius + the junction always replaces the plain "path" candidate — nobody + hits the intersection point exactly. + - **Bend** (`f=11.001`) at a ≥45° polyline vertex. + - **End** (`g=11.70x`) beside a free polyline endpoint. + - **Side of** (`g=11.10x`) for point features and compact areas + (buildings, ruins) from the feature centre; **outside corner** + (`g=11.50x`) when the control sits at a ≥60° corner of a compact + area. + - **Extended areas** (open land, marsh, thicket, …) get a location + too: **edge** (`g=11.20x`) when within 1 mm of the outline (inside + or outside), **part** (`g=11.30x`) when well inside a large area and + ≥2 mm off its centroid, **inside / outside corner** (`11.40x` / + `11.50x`) at a sharp boundary vertex. Direction is the bearing from + the area's centroid. A small area (control < 0.8 mm off centre) + says nothing. + - **Which of similar** (`c=0.20x`) when another point/compact-area of + the same D sits within the control circle. + - **Ranking**: candidates sort by distance, but an extended area ranks + by *depth from its edge* + 0.5 mm — being inside a clearing is not + the same as being at it, so a boulder half a millimetre away beats + the open land it stands in. (`distanceMm` in the response still + reports 0 for "inside".) + +Still out of scope: upper/lower column C (needs contours), between- +features, free-text dimensions, text/rectangle OCAD objects. ### Automatic overprint cuts diff --git a/docs/course-maps.md b/docs/course-maps.md index 0844aee..bdf4b60 100644 --- a/docs/course-maps.md +++ b/docs/course-maps.md @@ -235,9 +235,18 @@ graphics, saves an event graphic to the club library, and arms click-to-place for image objects. Image resize is free by default; **Ctrl/Cmd** keeps the aspect ratio and **Shift** crops the source graphic. -The control-description block previews the real rows of the selected preview -course through the shared `renderDescriptionBlockSvg`, so its printed height -is visible while laying out the page. +The control-description block is the same sheet the course editor shows on +the map: `resolve-layout.ts` builds it with the shared `buildDescriptionSheet` +(three-row IOF header — event / classes / course · length · climb — start row, +control rows, special-instruction rows, finish row with the measured +last-control → finish distance) and `renderDescriptionBlockSvg` draws it with +the IOF rules: thick header cells, thick rule under the start row, after every +third control and on either side of a special instruction, above the finish, +and thick verticals after columns C and F (A B C | D E F | G H). The layout +editor previews the identical block, so its printed height (3 header rows + +sheet rows) is visible while placing it; `validateCourseMap` uses the same +row/header count for the `description_outside_page` check. All-controls maps +keep a single title row over code-sorted control rows. Choose an object tool and click its intended page position to create it; `Escape` cancels an armed tool. Rectangles and polygons share a **Fill** diff --git a/docs/features.md b/docs/features.md index 44b8366..cba39ea 100644 --- a/docs/features.md +++ b/docs/features.md @@ -60,7 +60,7 @@ Selecting several courses shows all of them at once, with each leg labeled inlin The Course Editor (overflow menu) turns the map into an editing surface with contextual actions — no tool switching: click empty map and a floating menu offers **Add control** (the next unused punch code from the club control series, or the smallest free code ≥ 31 if none are defined / all are used) or **Add to course**; click an existing control for **Add to course**, **Radio**, **Edit description** and **Delete**; click a course leg to **Insert into course** at that spot. A standalone collapsible inventory card shows every club series with used/free and SRR state and lets you pin code assignment to a specific series instead of the priority order; enabling radio on a non-SRR code offers an inline swap to the first free SRR unit. The same empty-map menu offers **Add start** and **Add finish** — code-less controls auto-named "Start 1" / "Mål 1" — and works on a brand-new event with no controls at all (the click transform comes from the map's calibration). With several starts placed, selecting one offers **Use as start for [course]**, so different courses can start from different points. Drag any control to move it — if it's shared, an amber chip warns which other courses the move affects, and the selection menu shows "Also in: …". **Edit description** opens a full IOF control-description editor: symbol pickers for every sheet column with English/Swedish tooltips, a dimensions field, and a live preview row. A floating panel over the map builds whole courses: create or clone a course, click it to edit, reorder or remove controls, and undo/redo sequence edits with `Ctrl+Z` / `Ctrl+Shift+Z`. Legacy first-as-start / last-as-finish flags remain honored by geometry and export but are now read-only badges on the Courses page. Pencil icons on the Courses and Controls pages deep-link straight into the editor. Positions are stored in map millimetres and converted to WGS84 server-side; moving a control rebuilds every affected course. -Descriptions get a head start from the map itself: place a control and the same menu proposes what it sits on — boulder, path, marsh, building — read straight out of the uploaded OCAD map's objects, with a side-of direction ("N side") when the control is beside the feature rather than on it. One click fills the description, `Ctrl+Z` takes it back — and moving a control re-offers suggestions for the new spot, since the old description no longer applies. The course panel floats over the map itself, so course building keeps working in fullscreen, and with no course selected the description sheet lists every control under an "All controls" title instead of going blank. See [course-editor.md](course-editor.md) for the architecture and [control-descriptions-and-editor-geometry.md](control-descriptions-and-editor-geometry.md) for the backend contract. +Descriptions get a head start from the map itself: place a control and Oxygen auto-fills the top suggestion — boulder, path crossing, marsh, building — read straight out of the uploaded OCAD map, with side-of / which-of-similar / junction symbols when the geometry supports them. Moving a control offers a single replacement suggestion if the new spot differs. The description editor shows written titles on every symbol (including column C on mobile), all eight IOF columns, and a live summary sentence. Dragging a control uses a crosshair cursor and a magnifier loupe so placement (and east/west details) stay precise under a finger. The course panel floats over the map itself, so course building keeps working in fullscreen; with no course selected the description sheet lists every control under an "All controls" title. See [course-editor.md](course-editor.md) and [bugfix-control-description-columns.md](bugfix-control-description-columns.md). The overprint also cuts itself automatically, the way a careful course setter would by hand: control circles get slits and leg lines get gaps only for compact knolls and rock objects (rocky pits, boulders, large boulders and clusters). Long cliffs, paths, walls, buildings and areas stay uncut because the colour stack already keeps their ink readable. An **Auto cuts** toolbar toggle turns the automatic decoration off for the whole event when you want unbroken purple. Cuts are kept tight, clearing the small feature and no more, and existing editor courses migrate to the current cut algorithm on first view. Imported OCAD courses keep the slits authored in the file. Control numbers always have a narrow white halo for contrast over dense map ink in both the viewer and printed/PDF maps. Map ink that should sit above purple follows IOF colour-stack **profiles** (ISOM / ISSprOM / ISSkiOM / ISMTBOM, or auto from the file's Lower purple colour / map scale), with optional north lines kept under the course purple — see `docs/map-color-stack.md`. Events that share a club map share the tile cache via a content `renderKey`. diff --git a/e2e/course-editor.spec.ts b/e2e/course-editor.spec.ts index db217be..fa8291f 100644 --- a/e2e/course-editor.spec.ts +++ b/e2e/course-editor.spec.ts @@ -583,6 +583,74 @@ test.describe("Course editor", () => { await expect(page.getByTestId("editor-selection-ring")).toBeAttached({ timeout: 15000 }); }); + test("column C which-of-similar shows names and persists", async ({ page }) => { + // Column C used to be unlabeled arrow icons (same glyphs as column G) + // with names only in hover tooltips — unusable on mobile and easy to + // confuse with "side of". The picker must show the written title on + // every option, and the selection must round-trip through save/reload. + await selectCompetition(page); + await ensureCoursesAndMap(page); + await openEditor(page); + + const [code] = await pickClickableControlCodes(page, 1); + expect(code).toBeTruthy(); + + const hit = page.locator( + `[data-testid="editor-control-hit"][data-control-code="${code}"]`, + ); + const box = await hit.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); + await expect(page.getByTestId("editor-selected-info")).toContainText( + `Control ${code}`, + { timeout: 10000 }, + ); + + await page.getByTestId("editor-action-description").click(); + const dialog = page.getByTestId("desc-editor"); + await expect(dialog).toBeVisible(); + + // Visible written title next to / under the Northern option — not only + // a title= tooltip. + const northern = page.getByTestId("desc-opt-0.1N"); + await expect(northern).toBeVisible(); + await expect(page.getByTestId("desc-opt-label-0.1N")).toContainText(/Northern|Norra/i); + + await northern.click(); + await expect(northern).toHaveAttribute("aria-pressed", "true"); + // Boulder so the row is non-empty — scope to column D (E can reuse D codes). + await dialog.getByTestId("desc-section-d").getByTestId("desc-opt-2.4").click(); + + const waitForSave = () => + page.waitForResponse( + (res) => res.ok() && res.url().includes("control.update"), + { timeout: 15000 }, + ); + await Promise.all([waitForSave(), page.getByTestId("desc-save").click()]); + await expect(dialog).not.toBeVisible(); + + await page.reload(); + await expect(page.getByTestId("map-viewer")).toBeVisible({ timeout: 60000 }); + await expect(page.getByTestId("editor-control-hit").first()).toBeAttached({ + timeout: 30000, + }); + const box2 = await hit.boundingBox(); + expect(box2).not.toBeNull(); + await page.mouse.click(box2!.x + box2!.width / 2, box2!.y + box2!.height / 2); + await page.getByTestId("editor-action-description").click(); + await expect(dialog).toBeVisible(); + await expect(page.getByTestId("desc-opt-0.1N")).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(page.getByTestId("desc-summary")).toContainText(/Northern|Norra/i); + // Wipe so the next test does not inherit a partially-filled description + // on the same seed control (symbol buttons toggle on re-click). + await page.getByTestId("desc-clear").click(); + await Promise.all([waitForSave(), page.getByTestId("desc-save").click()]); + await expect(dialog).not.toBeVisible(); + }); + test("edit a control description via the contextual action", async ({ page }) => { await selectCompetition(page); await ensureCoursesAndMap(page); @@ -608,7 +676,7 @@ test.describe("Course editor", () => { }; const dialog = page.getByTestId("desc-editor"); - const boulder = page.getByTestId("desc-opt-2.4"); + const boulder = dialog.getByTestId("desc-section-d").getByTestId("desc-opt-2.4"); const dimInput = page.getByTestId("desc-dim-input"); /** @@ -645,15 +713,21 @@ test.describe("Course editor", () => { await expect(page.getByTestId("editor-selection-ring")).toBeAttached(); // ── Pick symbols (D: boulder, G: north side), type dimensions; - // the preview row renders the dimension text live. + // the preview row renders the dimension text live. Clear first so a + // leftover description from an earlier test cannot toggle-deselect. await page.getByTestId("editor-action-description").click(); await expect(dialog).toBeVisible(); + if (await page.getByTestId("desc-clear").isEnabled()) { + await page.getByTestId("desc-clear").click(); + } await boulder.click(); await expect(boulder).toHaveAttribute("aria-pressed", "true"); - await page.getByTestId("desc-opt-11.1N").click(); - await expect(page.getByTestId("desc-opt-11.1N")).toHaveAttribute("aria-pressed", "true"); + await dialog.getByTestId("desc-section-g").getByTestId("desc-opt-11.1N").click(); + await expect( + dialog.getByTestId("desc-section-g").getByTestId("desc-opt-11.1N"), + ).toHaveAttribute("aria-pressed", "true"); await dimInput.fill("1,5"); - await expect(page.getByTestId("desc-preview")).toContainText("1.5m"); + await expect(page.getByTestId("desc-preview")).toContainText("1.5"); // Save closes the dialog immediately and fires control.update in the // background — wait for the round-trip before reload, otherwise a // heavy map (stacked ink tiles) can abort the in-flight mutation. @@ -695,9 +769,9 @@ test.describe("Course editor", () => { await ensureCoursesAndMap(page); await openEditor(page); - // Place a control in the mapped terrain: the fixture's rough-open - // area and paths cover the control cluster, so the autodetect always - // has something to propose there. + // Place a control in the mapped terrain: autoDescribe fills the top + // suggestion on create, so the menu should NOT list suggestions for + // a freshly placed (already described) control. const spot = await findEmptyMapPoint(page); await page.mouse.click(spot.x, spot.y); await expect(page.getByTestId("editor-phantom")).toBeAttached({ timeout: 10000 }); @@ -707,18 +781,9 @@ test.describe("Course editor", () => { await expect(info).toBeVisible({ timeout: 15000 }); const newCode = ((await info.textContent()) ?? "").match(/(\d+)/)![1]; - // The new control has no description yet, so the menu offers what the - // map says it sits on. const suggestions = page.getByTestId("editor-suggestions"); - await expect(suggestions).toBeVisible({ timeout: 20000 }); - const firstSuggestion = suggestions.locator("button").first(); - const label = await firstSuggestion.getAttribute("data-suggestion-label"); - expect(label).toBeTruthy(); - - // Applying it fills column D (and the block disappears — the control - // now has a description). - await firstSuggestion.click(); - await expect(suggestions).not.toBeVisible({ timeout: 20000 }); + // Auto-applied on create → no suggestion menu for a fresh control. + await expect(suggestions).toHaveCount(0); const dialog = page.getByTestId("desc-editor"); const pressed = dialog.locator('[data-testid^="desc-opt-"][aria-pressed="true"]'); @@ -727,25 +792,16 @@ test.describe("Course editor", () => { await expect(dialog).toBeVisible(); }; + // The auto-applied description should show up in the editor. await openDescription(); await expect(async () => { expect(await pressed.count()).toBeGreaterThan(0); }).toPass({ timeout: 15000 }); await page.getByTestId("desc-cancel").click(); - // Ctrl+Z takes the applied description back off. - await page.keyboard.press("Control+z"); - await expect(suggestions).toBeVisible({ timeout: 20000 }); - await openDescription(); - await expect(pressed).toHaveCount(0); - await page.getByTestId("desc-cancel").click(); - - // Re-apply, then MOVE the control: a just-moved control gets - // suggestions again even though it already has a description — the - // old one described the old spot. - await firstSuggestion.click(); - await expect(suggestions).not.toBeVisible({ timeout: 20000 }); - await page.keyboard.press("Escape"); // close the menu so the empty-spot scan is unobstructed + // MOVE the control: a just-moved, already-described control may get + // a single top suggestion if the new spot differs. + await page.keyboard.press("Escape"); const hit = page.locator( `[data-testid="editor-control-hit"][data-control-code="${newCode}"]`, ); @@ -755,11 +811,24 @@ test.describe("Course editor", () => { await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); await page.mouse.down(); await page.mouse.move(dest.x, dest.y, { steps: 8 }); + // Loupe appears while dragging for precise placement. + await expect(page.getByTestId("editor-loupe")).toBeVisible(); + // The move re-describes the control server-side; wait for that + // round-trip so later steps see the settled state. + const moveSaved = page.waitForResponse( + (res) => res.ok() && res.url().includes("control.update"), + { timeout: 15000 }, + ); await page.mouse.up(); - await expect(suggestions).toBeVisible({ timeout: 20000 }); + await moveSaved; + + // After a move the menu may or may not show a suggestion depending + // on whether the new spot's top candidate differs — either way the + // loupe must be gone and the control still selected. + await expect(page.getByTestId("editor-loupe")).toHaveCount(0); + await expect(page.getByTestId("editor-context-menu")).toBeVisible({ timeout: 10000 }); - // Deselecting settles it: reselecting the (described) control shows - // no suggestions. + // Deselecting settles it. await page.keyboard.press("Escape"); const movedBox = await hit.boundingBox(); expect(movedBox).not.toBeNull(); @@ -794,16 +863,30 @@ test.describe("Course editor", () => { }).toPass({ timeout: 15000 }); await expect(sheet.locator('[data-testid="desc-title"]')).toHaveText("All controls"); - // Selecting a course switches the sheet to that course's card. - const firstCourse = page.getByTestId("editor-course-item").first(); - const courseName = await firstCourse.getAttribute("data-course-name"); - expect(courseName).toBeTruthy(); - await firstCourse.click(); + // Selecting a course switches the sheet to that course's card: the + // IOF header carries the event name (row 1) and the course name with + // length / climb (row 3) — not the course name twice. + // Course E is the fixture's shortest (8 controls), so the whole card + // — including the finish row — fits the on-map sheet at 720 px. + const courseName = "E"; + const courseItem = page.locator( + `[data-testid="editor-course-item"][data-course-name="${courseName}"]`, + ); + await courseItem.click(); await expect(page.getByTestId("editor-sequence")).toBeVisible({ timeout: 15000 }); - await expect(sheet.locator('[data-testid="desc-title"]')).toHaveText( - courseName!, + await expect(sheet.locator('[data-testid="desc-course-name"]')).toHaveText( + courseName, { timeout: 20000 }, ); + await expect(sheet.locator('[data-testid="desc-title"]')).toHaveText( + "My example tävling", + ); + // The finish row shows the measured last-control → finish distance + // even though no explicit finish length has been set on the course. + const finishLength = sheet.locator('[data-testid="desc-row-length"]').last(); + await expect(finishLength).toHaveText(/^\d+ m$/); + // Header block and every third control row close with a thick rule. + await expect(sheet.locator('[data-testid="desc-thick-rule"]').first()).toBeAttached(); }); test("clones a course and keeps legacy start/finish flags read-only", async ({ page }) => { diff --git a/e2e/structured-search-names.spec.ts b/e2e/structured-search-names.spec.ts index 8fad2d8..7629529 100644 --- a/e2e/structured-search-names.spec.ts +++ b/e2e/structured-search-names.spec.ts @@ -153,9 +153,11 @@ test.describe("Structured search — comma-separated names", () => { test("comma-separated in-lists still filter on multi-word class names", async ({ page }) => { await page.goto("/itest/runners"); - await expect(page.locator("span", { hasText: "runners" }).first()).toBeVisible({ - timeout: 15000, - }); + // The count span renders "0 runners" before the list query resolves — + // wait for a real baseline rather than reading the placeholder. + await expect + .poll(() => readRunnerCount(page), { timeout: 15000 }) + .toBeGreaterThan(0); const baseline = await readRunnerCount(page); // Per-item quoting keeps the list splittable while preserving spaces. diff --git a/packages/api/prisma/migrations/20260924180000_course_description_instructions/migration.sql b/packages/api/prisma/migrations/20260924180000_course_description_instructions/migration.sql new file mode 100644 index 0000000..4edd0ad --- /dev/null +++ b/packages/api/prisma/migrations/20260924180000_course_description_instructions/migration.sql @@ -0,0 +1,3 @@ +-- Course-level special-instruction / finish rows for the IOF description sheet. +ALTER TABLE "oxygen"."courses" + ADD COLUMN IF NOT EXISTS "description_instructions" JSONB; diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 8c6a02b..f4a2576 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -413,7 +413,7 @@ model Control { /// everything every connection). Default 'last' matches stock SI /// firmware behaviour and what the operator picks 95% of the time. autosendMode String @default("last") @map("autosend_mode") - /// IOF control description ({ c?, d?, g?, s?, f? } — OCAD text encoding). + /// IOF control description ({ c?, d?, e?, g?, s?, f?, h? } — OCAD text encoding). /// The description belongs to the control site, not to any course. description Json? removed Boolean @default(false) @@ -450,6 +450,9 @@ model Course { geometry Json? /// Provenance for `geometry`: 'ocd' (full routed legs from OCAD) or 'xml' (straight-line fallback from IOF XML). geometrySource String @default("") @map("geometry_source") + /// Course-level description-sheet instructions (special rows + finish variant). + /// Shape: { specials?: [{afterControlId, kind, lengthM?}], finish?: {kind, lengthM?} }. + descriptionInstructions Json? @map("description_instructions") removed Boolean @default(false) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6) diff --git a/packages/api/src/__tests__/course-map-layout.test.ts b/packages/api/src/__tests__/course-map-layout.test.ts index d08d444..70520da 100644 --- a/packages/api/src/__tests__/course-map-layout.test.ts +++ b/packages/api/src/__tests__/course-map-layout.test.ts @@ -46,7 +46,7 @@ const controls = [ ]; describe("resolveMapLayout description rows", () => { - it("builds sequenced rows with control descriptions for course maps", () => { + it("builds the full IOF sheet (header, start, controls, finish) for course maps", () => { const layout = resolveMapLayout({ kind: "course", template, @@ -54,17 +54,64 @@ describe("resolveMapLayout description rows", () => { name: "H40", lengthM: 3200, climbM: 40, + classes: [{ name: "H40" }, { name: "H45" }], controls, + descriptionInstructions: { + specials: [{ afterControlId: 1, kind: "13.1", lengthM: 60 }], + finish: { kind: "14.1" }, + }, + // Finish 40 mm east of the last control → 600 m at 1:15000. + geometry: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { type: "Point", coordinates: [70, 40] }, + properties: { symbolType: "finish", code: "F" }, + }, + ], + }, }, + event: { name: "Spring Cup", date: new Date("2026-05-01") }, mapScale: 15_000, }); + // Same model as the on-map sheet in the course editor. + expect(layout.descriptionHeader).toEqual({ + eventName: "Spring Cup", + classNames: "H40, H45", + courseName: "H40", + lengthKm: "3.20 km", + climbM: "40 m", + }); expect(layout.descriptionRows).toEqual([ - { sequence: 1, code: "31", description: { column_d: "boulder" } }, - { sequence: 2, code: "45", description: { column_d: "path" } }, + { kind: "start", code: "", description: null, symbolKey: "start" }, + { kind: "control", sequence: 1, code: "31", description: { column_d: "boulder" } }, + { kind: "special", code: "", symbolKey: "13.1", lengthM: 60 }, + { kind: "control", sequence: 2, code: "45", description: { column_d: "path" } }, + { kind: "finish", code: "", symbolKey: "14.1", lengthM: 600 }, ]); expect(layout.textValues.controls).toBe("2"); }); + it("falls back to the course name and no header text when no event is given", () => { + const layout = resolveMapLayout({ + kind: "course", + template, + course: { name: "H40", lengthM: 0, climbM: 0, controls }, + }); + expect(layout.descriptionHeader).toMatchObject({ + eventName: "", + courseName: "H40", + lengthKm: "", + }); + // No finish geometry / no map scale → no measured finish distance. + expect(layout.descriptionRows.at(-1)).toEqual({ + kind: "finish", + code: "", + symbolKey: "14.3", + }); + }); + it("builds unsequenced numerically sorted rows for all-controls maps", () => { const layout = resolveMapLayout({ kind: "all_controls", @@ -99,6 +146,8 @@ describe("resolveMapLayout description rows", () => { "100", ]); expect(layout.descriptionRows[0]).not.toHaveProperty("sequence"); + // All-controls maps keep the single title row. + expect(layout.descriptionHeader).toBeNull(); expect(layout.controls).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "start", code: "20", type: "start" }), diff --git a/packages/api/src/__tests__/description-autodetect.test.ts b/packages/api/src/__tests__/description-autodetect.test.ts index 7111642..bfabea7 100644 --- a/packages/api/src/__tests__/description-autodetect.test.ts +++ b/packages/api/src/__tests__/description-autodetect.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { compassBearing, + polygonCentroid, sideOfCode, suggestDescriptions, } from "../description-autodetect.js"; @@ -123,6 +124,156 @@ describe("suggestDescriptions", () => { const overprint = obj(703000, 1, [[0, 0]]); expect(suggestDescriptions([contour, overprint], 0, 0)).toEqual([]); }); + + it("detects a path crossing and sets F=crossing", () => { + const pathA = obj(PATH, 2, [[-5, 0], [5, 0]]); + const pathB = obj(PATH, 2, [[0, -5], [0, 5]]); + const [c] = suggestDescriptions([pathA, pathB], 0, 0); + expect(c.d).toBe("5.002"); + expect(c.f).toBe("10.001"); + // IOF layout: the second feature is listed in E even when it is the + // same kind — "path · path · crossing". + expect(c.e).toBe("5.002"); + expect(c.distanceMm).toBeCloseTo(0, 5); + }); + + it("detects a path/stream junction with two features", () => { + const path = obj(PATH, 2, [[-5, 0], [5, 0]]); + const stream = obj(304000, 2, [[0, 0], [0, 5]]); // ends on the path + const [c] = suggestDescriptions([path, stream], 0, 0); + expect(c.f).toBe("10.002"); + expect([c.d, c.e].sort()).toEqual(["3.004", "5.002"]); + }); + + it("detects a bend on a polyline", () => { + // Right-angle corner at (0,0). + const wall = obj(513000, 2, [[-5, 0], [0, 0], [0, 5]]); + const [c] = suggestDescriptions([wall], 0, 0); + expect(c.d).toBe("5.008"); + expect(c.f).toBe("11.001"); + }); + + it("detects the free end of a line feature", () => { + const path = obj(PATH, 2, [[0, 0], [10, 0]]); + const [c] = suggestDescriptions([path], 0, 0, { radiusMm: 1 }); + expect(c.d).toBe("5.002"); + expect(c.g).toBe("11.707"); // west end (line runs east from the tip) + // Slightly beside the tip (nearer the line than the endpoint) still + // reads as the end. + const [beside] = suggestDescriptions([path], 0.2, 0.3, { radiusMm: 1 }); + expect(beside.g).toBe("11.707"); + }); + + it("sets column C when two similar point features are nearby", () => { + // Two boulders; control on the northern one. + const south = obj(BOULDER, 1, [[0, 0]]); + const north = obj(BOULDER, 1, [[0, 2]]); + const [c] = suggestDescriptions([south, north], 0, 2); + expect(c.d).toBe("2.004"); + expect(c.c).toBe("0.201"); // Northern + }); + + it("detects a T-junction when the ending path stops just short of the other", () => { + // Real maps are rarely snapped: 0.25 mm gap between the end and the line. + const through = obj(PATH, 2, [[-5, 0], [5, 0]]); + const ending = obj(PATH, 2, [[0, 0.25], [0, 5]]); + const [c] = suggestDescriptions([through, ending], 0.2, 0.3); + expect(c.d).toBe("5.002"); + expect(c.e).toBe("5.002"); + expect(c.f).toBe("10.002"); + // One candidate for the pair — the second path is not listed again. + expect(suggestDescriptions([through, ending], 0.2, 0.3)).toHaveLength(1); + }); + + it("still reports the junction when the control is a millimetre off it", () => { + const through = obj(PATH, 2, [[-5, 0], [5, 0]]); + const ending = obj(PATH, 2, [[0, 0], [0, 5]]); + const [c] = suggestDescriptions([through, ending], 1.2, 0.3); + expect(c.f).toBe("10.002"); + }); + + it("puts the nearer line in column D at a mixed-feature crossing", () => { + const path = obj(PATH, 2, [[-5, 0], [5, 0]]); + const stream = obj(304000, 2, [[0, -5], [0, 5]]); + // Control sits right on the stream, a little away from the path. + const [c] = suggestDescriptions([path, stream], 0, 0.6); + expect(c.f).toBe("10.001"); + expect(c.d).toBe("3.004"); + expect(c.e).toBe("5.002"); + }); + + it("prefers a boulder beside the control over the open land it stands in", () => { + // Large rough-open area covering everything; boulder 0.8 mm away. + const open = obj(403000, 3, [[-20, -20], [20, -20], [20, 20], [-20, 20]]); + const boulder = obj(BOULDER, 1, [[0.8, 0]]); + const out = suggestDescriptions([open, boulder], 0, 0); + expect(out.map((c) => c.d)).toEqual(["2.004", "4.001"]); + expect(out[0].g).toBe("11.107"); // control is W of the boulder + // The area still reports distance 0 (inside) — ranking is what changed. + expect(out[1].distanceMm).toBe(0); + }); + + it("describes where in an extended area the control is", () => { + const marsh = obj(MARSH, 3, [[0, 0], [10, 0], [10, 10], [0, 10]]); + // Just inside the eastern boundary → "E edge". + expect(suggestDescriptions([marsh], 9.5, 5)[0].g).toBe("11.203"); + // Just outside the northern boundary → "N edge" as well. + expect(suggestDescriptions([marsh], 5, 10.8)[0].g).toBe("11.201"); + // Well inside, off-centre to the south-west → "SW part". + expect(suggestDescriptions([marsh], 2.5, 2.5)[0].g).toBe("11.306"); + // In the middle: nothing to say. + expect(suggestDescriptions([marsh], 5, 5)[0].g).toBeUndefined(); + }); + + it("stays quiet about position in a small area", () => { + // 1.4 mm marsh: every point is within the edge band, but the control + // is not meaningfully off-centre. + const small = obj(MARSH, 3, [[0, 0], [1.4, 0], [1.4, 1.4], [0, 1.4]]); + expect(suggestDescriptions([small], 0.7, 0.9)[0].g).toBeUndefined(); + }); + + it("detects inside and outside corners of area features", () => { + const marsh = obj(MARSH, 3, [[0, 0], [10, 0], [10, 10], [0, 10]]); + // Inside the NE corner. + expect(suggestDescriptions([marsh], 9.6, 9.6)[0].g).toBe("11.402"); + // Outside the SW corner. + expect(suggestDescriptions([marsh], -0.4, -0.4)[0].g).toBe("11.506"); + + // Buildings (compact, side-of allowed) get outside corners too … + const building = obj(BUILDING, 3, [[0, 0], [10, 0], [10, 10], [0, 10]]); + expect(suggestDescriptions([building], 10.4, -0.4)[0].g).toBe("11.504"); // SE + // … and plain side-of away from the corners. + expect(suggestDescriptions([building], 5, -1.5)[0].g).toBe("11.105"); // S + }); + + it("does not treat a flattened curve as a chain of corners", () => { + // A quarter-circle-ish boundary with 10° turns per vertex. + const pts: Array<[number, number]> = []; + for (let a = 0; a <= 90; a += 10) { + const r = (a * Math.PI) / 180; + pts.push([10 * Math.cos(r), 10 * Math.sin(r)]); + } + pts.push([0, 0]); + const blob = obj(MARSH, 3, pts); + const [c] = suggestDescriptions([blob], 7.0, 7.0); + expect(c.g).not.toMatch(/^11\.[45]/); + }); +}); + +describe("polygonCentroid", () => { + it("is area-weighted, so a densely sampled edge does not drag it", () => { + // Square 0..10 with the eastern edge sampled at 50 extra vertices. + const ring: Array<[number, number]> = [[0, 0], [1000, 0]]; + for (let i = 1; i < 50; i++) ring.push([1000, i * 20]); + ring.push([1000, 1000], [0, 1000]); + const [cx, cy] = polygonCentroid(ring); + expect(cx).toBeCloseTo(500, 3); + expect(cy).toBeCloseTo(500, 3); + }); + + it("falls back to the vertex mean for degenerate rings", () => { + expect(polygonCentroid([[0, 0], [10, 0]])).toEqual([5, 0]); + }); }); describe("compassBearing / sideOfCode", () => { diff --git a/packages/api/src/__tests__/event-map-objects.test.ts b/packages/api/src/__tests__/event-map-objects.test.ts index f1eee93..19b5491 100644 --- a/packages/api/src/__tests__/event-map-objects.test.ts +++ b/packages/api/src/__tests__/event-map-objects.test.ts @@ -69,4 +69,41 @@ describe("slimObject", () => { ]); expect(slim!.bbox).toEqual([0, 0, 100, 100]); }); + + it("flattens Bezier segments onto the drawn curve instead of keeping the handles", () => { + const bez1 = (x: number, y: number) => + Object.assign([x, y] as unknown as ArrayLike, { isFirstBezier: () => true }); + const bez2 = (x: number, y: number) => + Object.assign([x, y] as unknown as ArrayLike, { isSecondBezier: () => true }); + // A path from (0,0) to (1000,0) bowing north: handles 800 units above + // the chord. The drawn curve peaks at 0.75 × 800 = 600 — never at the + // handle height, and never at the handle x positions' raw values. + const slim = slimObject({ + sym: 505000, + objType: 2, + coordinates: [coord(0, 0), bez1(0, 800), bez2(1000, 800), coord(1000, 0)], + }); + expect(slim).not.toBeNull(); + const ys = slim!.coordinates.map((c) => c[1]); + expect(Math.max(...ys)).toBeCloseTo(600, 0); + expect(slim!.coordinates[0]).toEqual([0, 0]); + expect(slim!.coordinates.at(-1)).toEqual([1000, 0]); + // 1 anchor + 8 flattened steps. + expect(slim!.coordinates).toHaveLength(9); + expect(slim!.bbox[3]).toBeCloseTo(600, 0); + }); + + it("keeps a lone Bezier-flagged vertex when the segment is incomplete", () => { + const bez1 = (x: number, y: number) => + Object.assign([x, y] as unknown as ArrayLike, { isFirstBezier: () => true }); + const slim = slimObject({ + sym: 505000, + objType: 2, + coordinates: [coord(0, 0), bez1(50, 50)], + }); + expect(slim!.coordinates).toEqual([ + [0, 0], + [50, 50], + ]); + }); }); diff --git a/packages/api/src/__tests__/integration/course-maps.test.ts b/packages/api/src/__tests__/integration/course-maps.test.ts index be97f58..a45dcb1 100644 --- a/packages/api/src/__tests__/integration/course-maps.test.ts +++ b/packages/api/src/__tests__/integration/course-maps.test.ts @@ -225,10 +225,26 @@ describe("map templates and course maps", () => { ]); expect(maps[0].legs).toHaveLength(3); expect(maps[0].legs[0].gaps).toEqual([{ from: 0.4, to: 0.6 }]); - expect(maps[0].descriptionRows).toEqual([ - { sequence: 1, code: "31", description: { d: "2.004" } }, - { sequence: 2, code: "32", description: null }, + // Print sheet = the course editor's sheet: header + start + controls + + // finish (with the measured last-control → finish distance). + expect(maps[0].descriptionHeader).toMatchObject({ courseName: "Blue" }); + expect(maps[0].descriptionRows.map((row) => row.kind)).toEqual([ + "start", + "control", + "control", + "finish", ]); + expect(maps[0].descriptionRows[1]).toMatchObject({ + sequence: 1, + code: "31", + description: { d: "2.004" }, + }); + expect(maps[0].descriptionRows[2]).toMatchObject({ + sequence: 2, + code: "32", + description: null, + }); + expect(maps[0].descriptionRows[3]).toMatchObject({ kind: "finish", symbolKey: "14.3" }); }); it("supports several maps per course and a single all-controls map", async () => { diff --git a/packages/api/src/__tests__/integration/description-autodetect.test.ts b/packages/api/src/__tests__/integration/description-autodetect.test.ts index 6bf2d7f..7e89c73 100644 --- a/packages/api/src/__tests__/integration/description-autodetect.test.ts +++ b/packages/api/src/__tests__/integration/description-autodetect.test.ts @@ -109,6 +109,75 @@ describe("control.suggestDescription", () => { expect(codes).toContain("5.002"); }); +}); + +describe("autoDescribe on create / move", () => { + it("fills the suggestion on create and re-describes an untouched auto description on move", async () => { + const created = await caller.control.create({ + codes: "301", + xpos: BOULDER.x, + ypos: BOULDER.y + 1, + autoDescribe: true, + }); + let detail = await caller.control.detail({ id: created.id }); + expect(detail.description).toEqual({ d: "2.004", g: "11.101" }); // N side + + // Move to the west side: the auto description follows. + await caller.control.update({ + id: created.id, + xpos: BOULDER.x - 1.5, + ypos: BOULDER.y, + autoDescribe: true, + }); + detail = await caller.control.detail({ id: created.id }); + expect(detail.description).toEqual({ d: "2.004", g: "11.107" }); // W side + + // Move somewhere featureless: the stale auto description is cleared. + await caller.control.update({ + id: created.id, + xpos: 500, + ypos: 500, + autoDescribe: true, + }); + detail = await caller.control.detail({ id: created.id }); + expect(detail.description).toBeNull(); + }); + + it("leaves a hand-edited description alone on move", async () => { + const created = await caller.control.create({ + codes: "302", + xpos: BOULDER.x, + ypos: BOULDER.y + 1, + autoDescribe: true, + }); + // The user adds a size — no longer the raw autodetect result. + await caller.control.update({ + id: created.id, + description: { d: "2.004", g: "11.101", s: "1,5" }, + }); + await caller.control.update({ + id: created.id, + xpos: BOULDER.x - 1.5, + ypos: BOULDER.y, + autoDescribe: true, + }); + const detail = await caller.control.detail({ id: created.id }); + expect(detail.description).toEqual({ d: "2.004", g: "11.101", s: "1,5" }); + }); + + it("does nothing without autoDescribe", async () => { + const created = await caller.control.create({ + codes: "303", + xpos: BOULDER.x, + ypos: BOULDER.y + 1, + }); + await caller.control.update({ id: created.id, xpos: BOULDER.x - 1.5, ypos: BOULDER.y }); + const detail = await caller.control.detail({ id: created.id }); + expect(detail.description).toBeNull(); + }); +}); + +describe("control.suggestDescription — no map", () => { it("returns an empty list for an event with no map", async () => { const other = await createTestEvent("desc_autodetect_nomap"); try { diff --git a/packages/api/src/course-maps/map-page-svg.ts b/packages/api/src/course-maps/map-page-svg.ts index 3944c1a..81ff6c7 100644 --- a/packages/api/src/course-maps/map-page-svg.ts +++ b/packages/api/src/course-maps/map-page-svg.ts @@ -11,6 +11,7 @@ import { type CourseOverlayControl, type CourseOverlayLeg, type DescriptionRow, + type DescriptionSheetHeader, type MapRect, type MapTextValues, type MapWindow, @@ -41,6 +42,8 @@ export interface ComposeMapPageOptions { controls: CourseOverlayControl[]; legs: CourseOverlayLeg[]; descriptionRows: DescriptionRow[]; + /** Three-row IOF header for course maps; omitted → single `title` row. */ + descriptionHeader?: DescriptionSheetHeader | null; title: string; textValues: MapTextValues; allControls?: boolean; @@ -164,6 +167,7 @@ export function composeMapPageSvg(options: ComposeMapPageOptions): string { cellSizeMm: document.description.cellSizeMm, title: options.title, rows: options.descriptionRows, + ...(options.descriptionHeader ? { header: options.descriptionHeader } : {}), symbolResolver: (key) => IOF_SYMBOLS[key], }) : ""; diff --git a/packages/api/src/course-maps/resolve-layout.ts b/packages/api/src/course-maps/resolve-layout.ts index 1445094..ccb8ff3 100644 --- a/packages/api/src/course-maps/resolve-layout.ts +++ b/packages/api/src/course-maps/resolve-layout.ts @@ -10,15 +10,19 @@ */ import { + buildDescriptionSheet, mapTemplateSettingsSchema, mapWindowForFrame, courseMapObjectSchema, + type ControlDescription, + type CourseDescriptionInstructions, type CourseMapDocument, type CourseMapObject, type CourseMapOverrides, type CourseOverlayControl, type CourseOverlayLeg, type DescriptionRow, + type DescriptionSheetHeader, type MapPoint, type MapTextValues, type MapWindow, @@ -60,6 +64,36 @@ export interface LayoutCourseSource { classes?: Array<{ name: string }>; /** Course controls in course order. */ controls: LayoutControlSource[]; + /** `courses.description_instructions` JSONB (specials + finish variant). */ + descriptionInstructions?: unknown; +} + +/** Loose reader for the instructions JSONB — anything malformed → null. */ +function readInstructions(raw: unknown): CourseDescriptionInstructions | null { + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + const out: CourseDescriptionInstructions = {}; + if (Array.isArray(obj.specials)) { + out.specials = obj.specials + .filter((s): s is Record => !!s && typeof s === "object") + .filter((s) => typeof s.kind === "string") + .map((s) => ({ + afterControlId: + typeof s.afterControlId === "number" ? s.afterControlId : null, + kind: s.kind as string, + ...(typeof s.lengthM === "number" ? { lengthM: s.lengthM } : {}), + })); + } + if (obj.finish && typeof obj.finish === "object") { + const f = obj.finish as Record; + if (typeof f.kind === "string") { + out.finish = { + kind: f.kind, + ...(typeof f.lengthM === "number" ? { lengthM: f.lengthM } : {}), + }; + } + } + return out; } export interface ResolveMapLayoutInput { @@ -97,7 +131,16 @@ export interface ResolvedMapLayout { window: MapWindow; controls: CourseOverlayControl[]; legs: CourseOverlayLeg[]; + /** + * Description block rows. Course maps carry the full IOF sheet (start, + * controls, specials, finish); all-controls maps list control rows only. + */ descriptionRows: DescriptionRow[]; + /** + * Three-row IOF header (event / classes / course · length · climb) for + * course maps; null for all-controls maps, which keep a single title row. + */ + descriptionHeader: DescriptionSheetHeader | null; textValues: MapTextValues; /** * ISOM overprint enlargement `mapScale / printScale` (1 when the base @@ -282,13 +325,54 @@ export function resolveMapLayout( return rest; }); - const descriptionRows: DescriptionRow[] = regularControls - .filter((control) => control.type === "control") - .map((control, index) => ({ - ...(input.kind === "course" ? { sequence: index + 1 } : {}), + const controlRows = regularControls.filter( + (control) => control.type === "control", + ); + let descriptionRows: DescriptionRow[]; + let descriptionHeader: DescriptionSheetHeader | null = null; + if (input.kind === "course" && input.course) { + // Same row model as the on-map sheet in the course editor, so print + // and screen agree on header, start, specials and finish. + const finishGeom = geometryControls.find((c) => c.type === "finish"); + const lastControl = controlRows[controlRows.length - 1]; + const finishLengthM = + finishGeom && lastControl && input.mapScale + ? (Math.hypot(finishGeom.x - lastControl.x, finishGeom.y - lastControl.y) * + input.mapScale) / + 1000 + : null; + const sheet = buildDescriptionSheet({ + eventName: input.event?.name ?? "", + classNames: input.course.classes?.map((c) => c.name) ?? [], + courseName: input.course.name, + lengthM: input.course.lengthM, + climbM: input.course.climbM, + controls: controlRows.map((control, index) => { + const source = rawControls[regularControls.indexOf(control)]; + return { + id: source?.seq ?? index + 1, + code: control.code, + description: (control.description ?? null) as ControlDescription | null, + }; + }), + instructions: readInstructions(input.course.descriptionInstructions), + finishLengthM, + }); + descriptionHeader = sheet.header; + descriptionRows = sheet.rows.map((row) => ({ + kind: row.kind, + code: row.code, + ...(row.sequence !== undefined ? { sequence: row.sequence } : {}), + ...(row.description !== undefined ? { description: row.description } : {}), + ...(row.symbolKey !== undefined ? { symbolKey: row.symbolKey } : {}), + ...(row.lengthM !== undefined ? { lengthM: row.lengthM } : {}), + })); + } else { + descriptionRows = controlRows.map((control) => ({ code: control.code, description: control.description as DescriptionRow["description"], })); + } const legs = input.kind === "course" @@ -352,6 +436,7 @@ export function resolveMapLayout( controls, legs, descriptionRows, + descriptionHeader, overprintScale: (input.mapScale ?? printScale) / printScale, textValues: { event: input.event?.name ?? "", diff --git a/packages/api/src/course-maps/routes.ts b/packages/api/src/course-maps/routes.ts index 527e004..3ab25a8 100644 --- a/packages/api/src/course-maps/routes.ts +++ b/packages/api/src/course-maps/routes.ts @@ -197,6 +197,7 @@ async function renderPage( geometry: row.course.geometry, classes: row.course.classes, controls: row.course.courseControls.map(({ control }) => control), + descriptionInstructions: row.course.descriptionInstructions, } : null, allControls, @@ -218,6 +219,7 @@ async function renderPage( controls: layout.controls, legs: layout.legs, descriptionRows: layout.descriptionRows, + descriptionHeader: layout.descriptionHeader, title: row.course?.name ?? row.name, allControls: row.kind === "all_controls", textValues: layout.textValues, diff --git a/packages/api/src/description-autodetect.ts b/packages/api/src/description-autodetect.ts index d75f300..40abee9 100644 --- a/packages/api/src/description-autodetect.ts +++ b/packages/api/src/description-autodetect.ts @@ -1,15 +1,12 @@ /** * Control-description autodetect: rank the base-map features around a - * point and propose IOF description columns D (feature) and G (side of). + * point and propose IOF description columns D (feature), G (side of), + * F (crossing / junction / bend), E (second feature), and C (which of + * similar). * * Pure and unit-tested. The caller supplies the searchable objects from * `event-map-objects.ts` and a query point in paper mm; ISOM → column-D * translation comes from `ISOM_DESCRIPTION_MAP` in `@oxygen/shared`. - * - * Scope (v1): point features, plus simple lines and areas. Deliberately - * out of scope — they need information the map does not carry, or logic - * that would guess: column C (which of several), column F - * (junction/crossing), between-features, and column E dimensions. */ import { isomDescriptionFor, isomNumber } from "@oxygen/shared"; @@ -18,8 +15,14 @@ import type { SlimMapObject } from "./event-map-objects.js"; export interface DescriptionCandidate { /** Canonical OCAD column-D code, e.g. "2.004" (boulder). */ d: string; - /** Canonical OCAD column-G code (side of), when a direction applies. */ + /** Canonical OCAD column-G code (side of / end), when a direction applies. */ g?: string; + /** Column C: which of similar (e.g. "0.201" = Northern). */ + c?: string; + /** Column E: second feature of a crossing/junction (a column-D code). */ + e?: string; + /** Column F: crossing / junction / bend. */ + f?: string; /** ISOM symbol number the suggestion came from, e.g. 204. */ isom: number; /** Distance from the query point to the feature, in paper mm. */ @@ -37,6 +40,39 @@ export interface SuggestOptions { const DIRECTIONS = 8; /** Below this the control counts as *on* the feature, so no side-of. */ const SIDE_OF_MIN_MM = 0.3; +/** Max distance to an intersection for crossing/junction proposals. */ +const JUNCTION_RADIUS_MM = 1.5; +/** + * How far a line's endpoint may sit from the line it "meets" and still + * count as a junction. Mappers rarely snap exactly; 0.3 mm is well under + * the width of a drawn path. + */ +const JUNCTION_SNAP_MM = 0.3; +/** Max distance to a sharp vertex for bend proposals. */ +const BEND_RADIUS_MM = 0.7; +/** Min direction change (degrees) to count as a bend. */ +const BEND_MIN_DEG = 45; +/** Max distance to a free polyline endpoint for "end" proposals. */ +const END_RADIUS_MM = 0.5; +/** Neighbour search radius for which-of-similar (≈ control circle). */ +const SIMILAR_RADIUS_MM = 3.0; +/** Inside an area but this close to its boundary → "edge of". */ +const AREA_EDGE_MM = 1.0; +/** Min offset from the area centroid before edge / part get a direction. */ +const AREA_EDGE_MIN_OFFSET_MM = 0.8; +/** Min offset from the centroid of a large area to call it a "part". */ +const AREA_PART_MIN_OFFSET_MM = 2.0; +/** Max distance to a sharp boundary vertex for corner proposals. */ +const CORNER_RADIUS_MM = 0.7; +/** Min direction change (degrees) at a boundary vertex to be a corner. */ +const CORNER_MIN_DEG = 60; +/** + * Ranking handicap for extended areas (open land, marsh, …). Being + * *inside* one is not the same as being *at* it: a boulder half a + * millimetre away is the better description than the clearing it + * stands in, so areas rank by depth-from-edge plus this penalty. + */ +const AREA_RANK_PENALTY_MM = 0.5; /** OCAD units per paper mm. */ const UNITS_PER_MM = 100; @@ -58,6 +94,19 @@ function distSqToSegment(p: Pt, a: Pt, b: Pt): number { return (p[0] - cx) ** 2 + (p[1] - cy) ** 2; } +/** Closest point on segment a→b to p. */ +function closestOnSegment(p: Pt, a: Pt, b: Pt): Pt { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const lenSq = dx * dx + dy * dy; + let t = 0; + if (lenSq > 0) { + t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + } + return [a[0] + t * dx, a[1] + t * dy]; +} + /** Ray-cast point-in-polygon over a closed or open ring. */ function pointInRing(p: Pt, ring: Pt[]): boolean { let inside = false; @@ -90,7 +139,6 @@ function distanceToObject(p: Pt, obj: SlimMapObject): number { const d = distSqToSegment(p, coords[i], coords[i + 1]); if (d < best) best = d; } - // Areas are rings: close them so the last edge counts too. if (obj.objType === 3 && coords.length > 2) { const d = distSqToSegment(p, coords[coords.length - 1], coords[0]); if (d < best) best = d; @@ -98,36 +146,52 @@ function distanceToObject(p: Pt, obj: SlimMapObject): number { return Math.sqrt(best); } -/** The point a side-of direction is measured from. */ -function referencePoint(p: Pt, obj: SlimMapObject): Pt { - const coords = obj.coordinates; - if (obj.objType === 1 || coords.length === 1) return coords[0]; - if (obj.objType === 3) { - // Centroid of the ring vertices — good enough for a compact area - // (buildings, ruins), and only such areas carry `g`. +/** Ring vertices without the closing duplicate / consecutive repeats. */ +function ringVertices(coords: Pt[]): Pt[] { + const out: Pt[] = []; + for (const c of coords) { + const last = out[out.length - 1]; + if (last && last[0] === c[0] && last[1] === c[1]) continue; + out.push(c); + } + if (out.length > 1) { + const [f, l] = [out[0], out[out.length - 1]]; + if (f[0] === l[0] && f[1] === l[1]) out.pop(); + } + return out; +} + +/** Area-weighted polygon centroid (falls back to the vertex mean). */ +export function polygonCentroid(coords: Pt[]): Pt { + const ring = ringVertices(coords); + let area = 0, cx = 0, cy = 0; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const cross = ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]; + area += cross; + cx += (ring[j][0] + ring[i][0]) * cross; + cy += (ring[j][1] + ring[i][1]) * cross; + } + if (Math.abs(area) < 1e-6) { let sx = 0, sy = 0; - for (const [x, y] of coords) { + for (const [x, y] of ring) { sx += x; sy += y; } - return [sx / coords.length, sy / coords.length]; + return [sx / Math.max(1, ring.length), sy / Math.max(1, ring.length)]; } - // Lines: the closest point on the polyline, so "N side of the bridge" - // is relative to the part the control sits next to. + const f = 1 / (3 * area); + return [cx * f, cy * f]; +} + +/** The point a side-of direction is measured from. */ +function referencePoint(p: Pt, obj: SlimMapObject): Pt { + const coords = obj.coordinates; + if (obj.objType === 1 || coords.length === 1) return coords[0]; + if (obj.objType === 3) return polygonCentroid(coords); let best = Infinity; let bestPt: Pt = coords[0]; for (let i = 0; i < coords.length - 1; i++) { - const a = coords[i]; - const b = coords[i + 1]; - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const lenSq = dx * dx + dy * dy; - let t = 0; - if (lenSq > 0) { - t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq; - t = Math.max(0, Math.min(1, t)); - } - const cand: Pt = [a[0] + t * dx, a[1] + t * dy]; + const cand = closestOnSegment(p, coords[i], coords[i + 1]); const d = (p[0] - cand[0]) ** 2 + (p[1] - cand[1]) ** 2; if (d < best) { best = d; @@ -146,13 +210,312 @@ export function compassBearing(from: Pt, to: Pt): number { return (deg + 360) % 360; } +function directionIndex(bearingDeg: number): number { + return Math.round(((bearingDeg % 360) + 360) % 360 / 45) % DIRECTIONS; +} + /** * Canonical OCAD column-G "side of" code for a bearing: N → "11.101", - * NE → "11.102", … NW → "11.108" (see `control-description-options.ts`). + * NE → "11.102", … NW → "11.108". */ export function sideOfCode(bearingDeg: number): string { - const idx = Math.round(((bearingDeg % 360) + 360) % 360 / 45) % DIRECTIONS; - return `11.${100 + idx + 1}`; + return `11.${100 + directionIndex(bearingDeg) + 1}`; +} + +/** Column-G "edge of" (IOF 11.2): N → "11.201", … NW → "11.208". */ +export function edgeOfCode(bearingDeg: number): string { + return `11.${200 + directionIndex(bearingDeg) + 1}`; +} + +/** Column-G "part of" (IOF 11.3): N → "11.301", … NW → "11.308". */ +export function partOfCode(bearingDeg: number): string { + return `11.${300 + directionIndex(bearingDeg) + 1}`; +} + +/** Column-G "inside corner" (IOF 11.4): N → "11.401", … */ +export function insideCornerCode(bearingDeg: number): string { + return `11.${400 + directionIndex(bearingDeg) + 1}`; +} + +/** Column-G "outside corner" (IOF 11.5): N → "11.501", … */ +export function outsideCornerCode(bearingDeg: number): string { + return `11.${500 + directionIndex(bearingDeg) + 1}`; +} + +/** Column-G directional "end" codes: N → "11.701", … NW → "11.708". */ +export function endOfCode(bearingDeg: number): string { + return `11.${700 + directionIndex(bearingDeg) + 1}`; +} + +/** Column-C which-of-similar codes: N → "0.201", … NW → "0.208". */ +export function whichOfCode(bearingDeg: number): string { + return `0.${200 + directionIndex(bearingDeg) + 1}`; +} + +interface AreaInfo { + inside: boolean; + /** Distance from p to the ring boundary, mm (depth when inside). */ + boundaryMm: number; + centroid: Pt; + /** Sharp boundary vertex within CORNER_RADIUS_MM of p, if any. */ + corner: Pt | null; +} + +/** Where p sits relative to an area's outline. */ +function analyseArea(p: Pt, coords: Pt[]): AreaInfo { + const ring = ringVertices(coords); + const inside = ring.length > 2 && pointInRing(p, ring); + let best = Infinity; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const d = distSqToSegment(p, ring[j], ring[i]); + if (d < best) best = d; + } + let corner: Pt | null = null; + let cornerDist = CORNER_RADIUS_MM * UNITS_PER_MM; + if (ring.length > 2) { + for (let i = 0; i < ring.length; i++) { + const prev = ring[(i + ring.length - 1) % ring.length]; + const next = ring[(i + 1) % ring.length]; + const turn = 180 - angleBetween(prev, ring[i], next); + if (turn < CORNER_MIN_DEG) continue; + const d = Math.hypot(p[0] - ring[i][0], p[1] - ring[i][1]); + if (d <= cornerDist) { + cornerDist = d; + corner = ring[i]; + } + } + } + return { + inside, + boundaryMm: Math.sqrt(best) / UNITS_PER_MM, + centroid: polygonCentroid(ring), + corner, + }; +} + +/** + * Column-G location for a control at / in an extended area (marsh, open + * land, …): corner when at a sharp boundary vertex, edge when just inside + * or just outside the outline, part when well inside a large area. + */ +function areaLocation(p: Pt, info: AreaInfo): string | undefined { + const { centroid } = info; + if (info.corner) { + const bearing = compassBearing(centroid, info.corner); + return info.inside ? insideCornerCode(bearing) : outsideCornerCode(bearing); + } + const offsetMm = Math.hypot(p[0] - centroid[0], p[1] - centroid[1]) / UNITS_PER_MM; + if (offsetMm < AREA_EDGE_MIN_OFFSET_MM) return undefined; + const bearing = compassBearing(centroid, p); + if (!info.inside) return edgeOfCode(bearing); + if (info.boundaryMm <= AREA_EDGE_MM) return edgeOfCode(bearing); + if (offsetMm >= AREA_PART_MIN_OFFSET_MM) return partOfCode(bearing); + return undefined; +} + +/** Segment–segment intersection; null if parallel / non-overlapping. */ +function segmentIntersection(a: Pt, b: Pt, c: Pt, d: Pt): Pt | null { + const dx1 = b[0] - a[0]; + const dy1 = b[1] - a[1]; + const dx2 = d[0] - c[0]; + const dy2 = d[1] - c[1]; + const denom = dx1 * dy2 - dy1 * dx2; + if (Math.abs(denom) < 1e-9) return null; + const t = ((c[0] - a[0]) * dy2 - (c[1] - a[1]) * dx2) / denom; + const u = ((c[0] - a[0]) * dy1 - (c[1] - a[1]) * dx1) / denom; + if (t < -1e-6 || t > 1 + 1e-6 || u < -1e-6 || u > 1 + 1e-6) return null; + return [a[0] + t * dx1, a[1] + t * dy1]; +} + +function angleBetween(a: Pt, b: Pt, c: Pt): number { + const abx = a[0] - b[0]; + const aby = a[1] - b[1]; + const cbx = c[0] - b[0]; + const cby = c[1] - b[1]; + const dot = abx * cbx + aby * cby; + const mag = Math.hypot(abx, aby) * Math.hypot(cbx, cby); + if (mag < 1e-9) return 0; + const cos = Math.max(-1, Math.min(1, dot / mag)); + return (Math.acos(cos) * 180) / Math.PI; +} + +function pointNear(a: Pt, b: Pt, tol: number): boolean { + return Math.hypot(a[0] - b[0], a[1] - b[1]) <= tol; +} + +interface LineHit { + obj: SlimMapObject; + d: string; + isom: number; +} + +/** + * Crossing / junction between two line features near the query point. + * Crossing = both lines continue past the intersection; junction = an + * endpoint of one lies on the other. + */ +function findJunctionOrCrossing( + lines: LineHit[], + p: Pt, + radiusUnits: number, +): DescriptionCandidate | null { + let best: DescriptionCandidate | null = null; + const endpointTol = JUNCTION_SNAP_MM * UNITS_PER_MM; + + for (let i = 0; i < lines.length; i++) { + for (let j = i + 1; j < lines.length; j++) { + const a = lines[i]; + const b = lines[j]; + const ac = a.obj.coordinates; + const bc = b.obj.coordinates; + if (ac.length < 2 || bc.length < 2) continue; + + // Segment intersections. Near an endpoint of either line → junction; + // both continue past the hit → crossing. + for (let ai = 0; ai < ac.length - 1; ai++) { + for (let bi = 0; bi < bc.length - 1; bi++) { + const hit = segmentIntersection(ac[ai], ac[ai + 1], bc[bi], bc[bi + 1]); + if (!hit) continue; + const dist = Math.hypot(p[0] - hit[0], p[1] - hit[1]); + if (dist > radiusUnits) continue; + const distanceMm = dist / UNITS_PER_MM; + const nearEndA = + pointNear(hit, ac[0], endpointTol) || + pointNear(hit, ac[ac.length - 1], endpointTol); + const nearEndB = + pointNear(hit, bc[0], endpointTol) || + pointNear(hit, bc[bc.length - 1], endpointTol); + const f = nearEndA || nearEndB ? "10.002" : "10.001"; + const cand = makePairCandidate(a, b, f, distanceMm, p); + if (!best || cand.distanceMm < best.distanceMm) best = cand; + } + } + + // Endpoint of one on the other → junction. + for (const [endObj, other] of [ + [a, b], + [b, a], + ] as const) { + const ends = [ + endObj.obj.coordinates[0], + endObj.obj.coordinates[endObj.obj.coordinates.length - 1], + ]; + for (const end of ends) { + const distToOther = distanceToObject(end, other.obj); + if (distToOther > endpointTol) continue; + const dist = Math.hypot(p[0] - end[0], p[1] - end[1]); + if (dist > radiusUnits) continue; + const distanceMm = dist / UNITS_PER_MM; + const cand = makePairCandidate(endObj, other, "10.002", distanceMm, p); + if (!best || cand.distanceMm < best.distanceMm) best = cand; + } + } + } + } + return best; +} + +function makePairCandidate( + a: LineHit, + b: LineHit, + f: string, + distanceMm: number, + p: Pt, +): DescriptionCandidate { + // IOF sheet convention for a crossing / junction: column D is the + // first feature, column E the second, F the combination — *also* when + // both are the same kind ("path junction" = path · path · junction). + // The line the control is nearer to becomes column D. + const [near, far] = + distanceToObject(p, a.obj) <= distanceToObject(p, b.obj) ? [a, b] : [b, a]; + return { + d: near.d, + e: far.d, + f, + isom: near.isom, + distanceMm, + }; +} + +/** Sharp bend on a single polyline near the query point. */ +function findBend( + lines: LineHit[], + p: Pt, + radiusUnits: number, +): DescriptionCandidate | null { + let best: DescriptionCandidate | null = null; + for (const line of lines) { + const coords = line.obj.coordinates; + for (let i = 1; i < coords.length - 1; i++) { + const turn = 180 - angleBetween(coords[i - 1], coords[i], coords[i + 1]); + if (turn < BEND_MIN_DEG) continue; + const dist = Math.hypot(p[0] - coords[i][0], p[1] - coords[i][1]); + if (dist > radiusUnits) continue; + const distanceMm = dist / UNITS_PER_MM; + if (!best || distanceMm < best.distanceMm) { + best = { d: line.d, f: "11.001", isom: line.isom, distanceMm }; + } + } + } + return best; +} + +/** + * Free polyline endpoint near the control → directional "end" in G. + * An endpoint is free when no other line of the same D ends within + * 0.3 mm of it (so a T-junction isn't reported as an end). + */ +function findEnd( + lines: LineHit[], + p: Pt, + radiusUnits: number, +): DescriptionCandidate | null { + let best: DescriptionCandidate | null = null; + const shareTol = 30; // 0.3 mm + + for (const line of lines) { + const coords = line.obj.coordinates; + if (coords.length < 2) continue; + const ends: Array<{ pt: Pt; inward: Pt }> = [ + { pt: coords[0], inward: coords[1] }, + { pt: coords[coords.length - 1], inward: coords[coords.length - 2] }, + ]; + for (const { pt, inward } of ends) { + // An endpoint that lies on another line is a junction, not an end. + const onOther = lines.some( + (other) => + other !== line && + distanceToObject(pt, other.obj) <= shareTol, + ); + if (onOther) continue; + const shared = lines.some( + (other) => + other !== line && + other.d === line.d && + (pointNear(pt, other.obj.coordinates[0], shareTol) || + pointNear( + pt, + other.obj.coordinates[other.obj.coordinates.length - 1], + shareTol, + )), + ); + if (shared) continue; + const dist = Math.hypot(p[0] - pt[0], p[1] - pt[1]); + if (dist > radiusUnits) continue; + const distanceMm = dist / UNITS_PER_MM; + // Bearing from the feature towards the endpoint (= along the line). + const bearing = compassBearing(inward, pt); + if (!best || distanceMm < best.distanceMm) { + best = { + d: line.d, + g: endOfCode(bearing), + isom: line.isom, + distanceMm, + }; + } + } + } + return best; } /** @@ -161,7 +524,8 @@ export function sideOfCode(bearingDeg: number): string { * Nearest feature first, one candidate per column-D code — several ISOM * symbols share one (footpath 505 and vehicle track 504 are both 5.002), * and the user picks a *description*, not a map symbol — capped at - * `limit`. + * `limit`. Junction / crossing / bend / end / which-of-similar enrich + * the nearest hits when the geometry supports it. */ export function suggestDescriptions( objects: SlimMapObject[], @@ -178,6 +542,22 @@ export function suggestDescriptions( /** Nearest hit per column-D code. */ const best = new Map(); + /** + * Ranking distance per candidate. Equals `distanceMm` except for + * extended areas, which rank by depth-from-edge + penalty so a point + * feature beside the control beats the clearing it stands in. + */ + const ranks = new WeakMap(); + const rankOf = (c: DescriptionCandidate) => ranks.get(c) ?? c.distanceMm; + const nearbyLines: LineHit[] = []; + /** All nearby point/compact-area hits (for which-of-similar). */ + const nearbyPoints: Array<{ + d: string; + isom: number; + ref: Pt; + distanceMm: number; + g?: boolean; + }> = []; for (const obj of objects) { const [minX, minY, maxX, maxY] = obj.bbox; @@ -193,22 +573,141 @@ export function suggestDescriptions( const dist = distanceToObject(p, obj); if (dist > radius) continue; const distanceMm = dist / UNITS_PER_MM; + const isom = isomNumber(obj.sym); - const existing = best.get(entry.d); - if (existing && existing.distanceMm <= distanceMm) continue; + if (obj.objType === 2 && obj.coordinates.length >= 2) { + nearbyLines.push({ obj, d: entry.d, isom }); + } + if (obj.objType === 1 || (obj.objType === 3 && entry.g)) { + nearbyPoints.push({ + d: entry.d, + isom, + ref: referencePoint(p, obj), + distanceMm, + g: entry.g, + }); + } const candidate: DescriptionCandidate = { d: entry.d, - isom: isomNumber(obj.sym), + isom, distanceMm, }; - if (entry.g && distanceMm >= SIDE_OF_MIN_MM) { + let rank = distanceMm; + const isArea = obj.objType === 3 && obj.coordinates.length > 2; + if (isArea) { + const info = analyseArea(p, obj.coordinates); + if (entry.g) { + // Compact area (building, ruin, rock pillar): side-of from the + // centroid, or the outside corner when the control sits at one. + if (!info.inside && distanceMm >= SIDE_OF_MIN_MM) { + candidate.g = info.corner + ? outsideCornerCode(compassBearing(info.centroid, info.corner)) + : sideOfCode(compassBearing(info.centroid, p)); + } + } else { + rank = info.boundaryMm + AREA_RANK_PENALTY_MM; + const g = areaLocation(p, info); + if (g) candidate.g = g; + } + } else if (entry.g && distanceMm >= SIDE_OF_MIN_MM) { candidate.g = sideOfCode(compassBearing(referencePoint(p, obj), p)); } + + const existing = best.get(entry.d); + if (existing && rankOf(existing) <= rank) continue; + ranks.set(candidate, rank); best.set(entry.d, candidate); } + // Enrich / override with junction, crossing, bend, end — these beat + // plain line hits when the control sits on the intersection. + const junction = findJunctionOrCrossing( + nearbyLines, + p, + JUNCTION_RADIUS_MM * UNITS_PER_MM, + ); + if (junction) { + // Within JUNCTION_RADIUS_MM the junction is the better description + // of the same line than "the path" alone — even when the control is + // nearer to the line than to the exact intersection point (nobody + // places a circle centre with 0.1 mm precision). Other features + // still compete on the junction's own distance. + best.set(junction.d, junction); + // Drop the second feature's plain candidate so we don't list it twice + // (for a same-kind junction the second feature *is* the candidate). + if (junction.e && junction.e !== junction.d) best.delete(junction.e); + } + + const bend = findBend(nearbyLines, p, BEND_RADIUS_MM * UNITS_PER_MM); + if (bend) { + const existing = best.get(bend.d); + if (!existing || bend.distanceMm < existing.distanceMm || !existing.f) { + best.set(bend.d, { + ...bend, + // Preserve a side-of if the bend candidate has none and the + // existing one does (rare for lines). + g: bend.g ?? existing?.g, + }); + } + } + + const end = findEnd(nearbyLines, p, END_RADIUS_MM * UNITS_PER_MM); + if (end) { + const existing = best.get(end.d); + // Same reasoning as for junctions: beside the tip of a line the + // "end" is the description, unless a combination (junction / bend) + // already claimed the feature. + if (!existing || !existing.f) { + best.set(end.d, { + ...(existing ?? end), + ...end, + // End wins over a plain side-of for the same feature. + g: end.g, + }); + } + } + + // Which of similar: for each nearest point feature, if another of the + // same D exists within the control circle, set column C from the + // bearing of the chosen feature relative to the others' centroid. + for (const [d, cand] of best) { + if (cand.f || cand.c) continue; + const peers = nearbyPoints.filter( + (pt) => pt.d === d && pt.distanceMm <= SIMILAR_RADIUS_MM, + ); + if (peers.length < 2) continue; + // Chosen = nearest peer (matches cand). + peers.sort((a, b) => a.distanceMm - b.distanceMm); + const chosen = peers[0]; + const others = peers.slice(1); + let sx = 0, sy = 0; + for (const o of others) { + sx += o.ref[0]; + sy += o.ref[1]; + } + const centroid: Pt = [sx / others.length, sy / others.length]; + // Only set C when the chosen feature is meaningfully offset. + if (Math.hypot(chosen.ref[0] - centroid[0], chosen.ref[1] - centroid[1]) < 20) { + continue; + } + cand.c = whichOfCode(compassBearing(centroid, chosen.ref)); + } + + // Prefer geometric combinations (crossing / junction / bend) over plain + // or end hits at the same spot: drop rivals that share a D/E with a + // combination candidate. + for (const cand of [...best.values()]) { + if (!cand.f) continue; + for (const [d, other] of [...best.entries()]) { + if (other === cand) continue; + if (other.d === cand.e || (other.e && other.e === cand.d)) { + best.delete(d); + } + } + } + return [...best.values()] - .sort((a, b) => a.distanceMm - b.distanceMm || a.isom - b.isom) + .sort((a, b) => rankOf(a) - rankOf(b) || a.isom - b.isom) .slice(0, limit); } diff --git a/packages/api/src/event-map-objects.ts b/packages/api/src/event-map-objects.ts index 16fbdf3..a238e38 100644 --- a/packages/api/src/event-map-objects.ts +++ b/packages/api/src/event-map-objects.ts @@ -13,9 +13,11 @@ * bulk of a club map — so the retained set stays small. * * Coordinates are OCAD's native 1/100 mm paper units (`TdPoly` already - * applies the `>> 8` shift). Bezier control points are treated as - * ordinary polyline vertices: for a distance-to-feature estimate at - * millimetre scale the difference is irrelevant. + * applies the `>> 8` shift). Bezier segments are flattened into short + * polyline runs: junction / crossing / corner detection compares + * geometry at a few tenths of a millimetre, and a curved path whose + * control handles sit a millimetre off the drawn line would otherwise + * never register as meeting the path it ends on. */ import { isomDescriptionFor } from "@oxygen/shared"; @@ -57,30 +59,87 @@ const cache = new Map< */ const MAX_CACHED_EVENTS = 3; +/** One `TdPoly` coordinate as ocad2geojson exposes it. */ +export type RawOcadCoordinate = ArrayLike & { + isFirstHolePoint?: () => boolean; + isFirstBezier?: () => boolean; + isSecondBezier?: () => boolean; +}; + interface RawOcadObject { sym: number; objType: number; - coordinates: Array< - ArrayLike & { isFirstHolePoint?: () => boolean } - >; + coordinates: RawOcadCoordinate[]; } -/** Slim one OCAD object down, or null when it isn't searchable. */ -export function slimObject(obj: RawOcadObject): SlimMapObject | null { - if (obj.objType !== 1 && obj.objType !== 2 && obj.objType !== 3) return null; - if (!isomDescriptionFor(obj.sym)) return null; +/** Straight segments per cubic Bezier when flattening. */ +const BEZIER_STEPS = 8; - const coords: Array<[number, number]> = []; - for (const c of obj.coordinates ?? []) { +function finitePoint(c: RawOcadCoordinate): [number, number] | null { + const x = c[0]; + const y = c[1]; + return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null; +} + +/** + * Flatten an OCAD coordinate list. A cubic segment is stored as + * `P0, C1 (first-bezier flag), C2 (second-bezier flag), P3`; everything + * else is a plain vertex. Stops at the first hole point of an area. + */ +export function flattenOcadCoordinates( + raw: RawOcadCoordinate[], + objType: number, +): Array<[number, number]> { + const out: Array<[number, number]> = []; + let i = 0; + while (i < raw.length) { + const c = raw[i]; // Areas may carry holes after the outer ring; the search only needs // the outline, and ignoring holes at most makes a control inside a // hole look "on" the feature — acceptable for a suggestion. - if (coords.length > 0 && obj.objType === 3 && c.isFirstHolePoint?.()) break; - const x = c[0]; - const y = c[1]; - if (!Number.isFinite(x) || !Number.isFinite(y)) continue; - coords.push([x, y]); + if (out.length > 0 && objType === 3 && c.isFirstHolePoint?.()) break; + + if ( + out.length > 0 && + c.isFirstBezier?.() && + i + 2 < raw.length && + raw[i + 1].isSecondBezier?.() + ) { + const p0 = out[out.length - 1]; + const c1 = finitePoint(c); + const c2 = finitePoint(raw[i + 1]); + const p3 = finitePoint(raw[i + 2]); + if (c1 && c2 && p3) { + for (let s = 1; s <= BEZIER_STEPS; s++) { + const t = s / BEZIER_STEPS; + const mt = 1 - t; + const a = mt * mt * mt; + const b = 3 * mt * mt * t; + const cc = 3 * mt * t * t; + const d = t * t * t; + out.push([ + a * p0[0] + b * c1[0] + cc * c2[0] + d * p3[0], + a * p0[1] + b * c1[1] + cc * c2[1] + d * p3[1], + ]); + } + i += 3; + continue; + } + } + + const p = finitePoint(c); + if (p) out.push(p); + i++; } + return out; +} + +/** Slim one OCAD object down, or null when it isn't searchable. */ +export function slimObject(obj: RawOcadObject): SlimMapObject | null { + if (obj.objType !== 1 && obj.objType !== 2 && obj.objType !== 3) return null; + if (!isomDescriptionFor(obj.sym)) return null; + + const coords = flattenOcadCoordinates(obj.coordinates ?? [], obj.objType); if (coords.length === 0) return null; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; diff --git a/packages/api/src/ocd-course-parser.ts b/packages/api/src/ocd-course-parser.ts index d65c441..1eecdf8 100644 --- a/packages/api/src/ocd-course-parser.ts +++ b/packages/api/src/ocd-course-parser.ts @@ -372,8 +372,10 @@ export function parseOCDCourseData(fileData: Buffer): ParsedOCDCourseData { if (part.startsWith("d")) desc.d = part.slice(1); else if (part.startsWith("g")) desc.g = part.slice(1); else if (part.startsWith("c") && part.includes(".")) desc.c = part.slice(1); + else if (part.startsWith("e") && part.includes(".")) desc.e = part.slice(1); else if (part.startsWith("s") && /^s\d/.test(part)) desc.s = part.slice(1); else if (part.startsWith("f") && part.includes(".")) desc.f = part.slice(1); + else if (part.startsWith("h") && part.includes(".")) desc.h = part.slice(1); } if (desc.d) controlDescriptions.set(resolvedCode, desc); } diff --git a/packages/api/src/routers/control.ts b/packages/api/src/routers/control.ts index 06206a0..b851d3e 100644 --- a/packages/api/src/routers/control.ts +++ b/packages/api/src/routers/control.ts @@ -22,7 +22,7 @@ import { emitControlUpserted, emitCourseUpserted } from "../referenceJournal.js" import { Prisma as PrismaNs } from "../generated/prisma/client.js"; import { loadEventCrs } from "../event-crs.js"; import { loadEventMapObjects } from "../event-map-objects.js"; -import { suggestDescriptions } from "../description-autodetect.js"; +import { suggestDescriptions, type DescriptionCandidate } from "../description-autodetect.js"; import { mapMmToWgs84 } from "../map-projection.js"; import { rebuildCourseGeometry } from "../course-geometry.js"; @@ -260,11 +260,73 @@ const batteryMvSchema = z.number().int().min(500).max(10_000).optional(); const controlDescriptionSchema = z.object({ c: z.string().max(20).optional(), d: z.string().max(20).optional(), + e: z.string().max(20).optional(), g: z.string().max(20).optional(), s: z.string().max(20).optional(), f: z.string().max(20).optional(), + h: z.string().max(20).optional(), }); +/** Map an autodetect candidate onto a storable ControlDescription. */ +function candidateToDescription( + c: DescriptionCandidate, +): ControlDescription { + return { + d: c.d, + ...(c.c ? { c: c.c } : {}), + ...(c.e ? { e: c.e } : {}), + ...(c.f ? { f: c.f } : {}), + ...(c.g ? { g: c.g } : {}), + }; +} + +async function autoDescribeAt( + db: PrismaClient, + eventId: bigint, + x: number, + y: number, +): Promise { + if (x === 0 && y === 0) return null; + try { + const objects = await loadEventMapObjects(db, eventId); + if (!objects || objects.length === 0) return null; + const [top] = suggestDescriptions(objects, x, y, { limit: 1 }); + return top ? candidateToDescription(top) : null; + } catch { + return null; + } +} + +const DESCRIPTION_FIELDS = ["c", "d", "e", "s", "f", "g", "h"] as const; + +/** Field-wise equality of two descriptions (empty strings count as unset). */ +function descriptionsEqual( + a: ControlDescription | null | undefined, + b: ControlDescription | null | undefined, +): boolean { + if (!a || !b) return !a && !b; + return DESCRIPTION_FIELDS.every( + (k) => (a[k] || undefined) === (b[k] || undefined), + ); +} + +/** + * Was this control's description produced by the autodetect at its + * current position and never touched since? Then a move may replace it + * with the suggestion for the new spot; a hand-edited description is + * left alone (the editor offers the new suggestion in the menu instead). + */ +async function isUntouchedAutoDescription( + db: PrismaClient, + eventId: bigint, + control: { xpos: number; ypos: number; description: unknown }, +): Promise { + const current = control.description as ControlDescription | null; + if (!current) return true; + const auto = await autoDescribeAt(db, eventId, control.xpos, control.ypos); + return descriptionsEqual(auto, current); +} + /** Read the JSONB description column into the shared DTO type. */ function descriptionDto(row: { description: unknown }): ControlDescription | null { return (row.description as ControlDescription | null) ?? null; @@ -511,6 +573,11 @@ export const controlRouter = router({ xpos: z.number().finite().optional(), ypos: z.number().finite().optional(), description: controlDescriptionSchema.optional(), + /** + * When true and no explicit `description` is given, fill the + * top map-based suggestion at (xpos, ypos) in the same write. + */ + autoDescribe: z.boolean().optional(), }) .refine((v) => (v.xpos === undefined) === (v.ypos === undefined), { message: "xpos and ypos must be provided together", @@ -577,6 +644,22 @@ export const controlRouter = router({ name = `${statusEnum === "start" ? "Start" : "Mål"} ${count + 1}`; } + let description = input.description as ControlDescription | undefined; + if ( + !description && + input.autoDescribe && + hasPosition && + !isStartFinish + ) { + description = + (await autoDescribeAt( + ctx.db, + ctx.event.id, + input.xpos!, + input.ypos!, + )) ?? undefined; + } + // Table write + control.upserted journal entry commit together. const created = await ctx.db.$transaction(async (tx) => { const c = await tx.control.create({ @@ -590,8 +673,8 @@ export const controlRouter = router({ ...(hasPosition ? { xpos: input.xpos!, ypos: input.ypos!, lat: wgs!.lat, lng: wgs!.lng } : {}), - ...(input.description - ? { description: input.description as Record } + ...(description + ? { description: description as Record } : {}), }, select: { id: true, seq: true, codes: true, name: true, status: true }, @@ -631,6 +714,13 @@ export const controlRouter = router({ ypos: z.number().finite().optional(), /** IOF description; null clears it. */ description: controlDescriptionSchema.nullable().optional(), + /** + * When true, fill the top map-based suggestion at the (new) + * position if the control has no description, or if its current + * description is the untouched autodetect result for the old + * position. Hand-edited descriptions are left alone. + */ + autoDescribe: z.boolean().optional(), }) .refine((v) => (v.xpos === undefined) === (v.ypos === undefined), { message: "xpos and ypos must be provided together", @@ -665,6 +755,30 @@ export const controlRouter = router({ data.lng = wgs.lng; } + // Auto-describe on request when the control has no description, or + // when the one it has is the untouched autodetect result for its + // old position (so a move keeps "N side of boulder" honest). A + // hand-edited description is never overwritten here — the editor + // menu offers the new suggestion instead. + if ( + input.autoDescribe && + input.description === undefined && + input.xpos !== undefined && + input.ypos !== undefined && + (!c.description || + (positionChanged && + (await isUntouchedAutoDescription(ctx.db, ctx.event.id, c)))) + ) { + const suggested = await autoDescribeAt( + ctx.db, + ctx.event.id, + input.xpos, + input.ypos, + ); + if (suggested) data.description = suggested; + else if (c.description) data.description = PrismaNs.DbNull; + } + await ctx.db.$transaction(async (tx) => { await tx.control.update({ where: { id: c.id }, data }); await emitControlUpserted(tx, ctx.event.id, c.id); diff --git a/packages/api/src/routers/course.ts b/packages/api/src/routers/course.ts index 12c4171..4276459 100644 --- a/packages/api/src/routers/course.ts +++ b/packages/api/src/routers/course.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { router, coursesViewProcedure, kioskOrCoursesViewProcedure, coursesEditProcedure, coursesEditRaceProcedure, manageProcedure } from "../trpc.js"; -import type { PrismaClient, Prisma as PrismaNs } from "../generated/prisma/client.js"; +import type { PrismaClient } from "../generated/prisma/client.js"; +import { Prisma as PrismaNs } from "../generated/prisma/client.js"; import { controlStatusToValue, valueToControlStatus, @@ -10,8 +11,10 @@ import { type CourseSummary, type CourseDetail, type ControlDescription, + type CourseDescriptionInstructions, type ExpectedPosition, ControlStatus, + pruneDescriptionInstructions, } from "@oxygen/shared"; import { parseIOFCourseDataWithGeometry, @@ -69,6 +72,32 @@ import { classUpsertPayload, } from "../referenceJournal.js"; +/** Course-level description-sheet instructions (specials + finish). */ +const descriptionInstructionsSchema = z + .object({ + specials: z + .array( + z.object({ + afterControlId: z.number().int().nullable(), + kind: z.string().min(1).max(20), + lengthM: z.number().finite().nonnegative().optional(), + }), + ) + .optional(), + finish: z + .object({ + kind: z.string().min(1).max(20), + lengthM: z.number().finite().nonnegative().optional(), + }) + .optional(), + }) + .nullable(); + +function instructionsDto(raw: unknown): CourseDescriptionInstructions | null { + if (!raw || typeof raw !== "object") return null; + return raw as CourseDescriptionInstructions; +} + // ─── Class-name matching for the import preview ───────────── export type ClassMatchType = "exact" | "normalized" | "substring" | "none"; @@ -393,6 +422,7 @@ async function loadCourseDetail( className: cl.name, runnerCount: runnerCountMap.get(cl.id) ?? 0, })), + descriptionInstructions: instructionsDto(c.descriptionInstructions), }; } @@ -464,6 +494,7 @@ export const courseRouter = router({ finishControlId: c.finishControlId ? finishIdByUuid.get(c.finishControlId) ?? null : null, + descriptionInstructions: instructionsDto(c.descriptionInstructions), }; }, ); @@ -638,6 +669,7 @@ export const courseRouter = router({ */ startControlId: z.number().int().nullable().optional(), finishControlId: z.number().int().nullable().optional(), + descriptionInstructions: descriptionInstructionsSchema.optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -692,6 +724,36 @@ export const courseRouter = router({ ), ) : []; + if (input.descriptionInstructions !== undefined) { + // When the sequence is also changing, prune specials that would + // otherwise dangle off a control that's leaving the course. + const nextIds = + input.controlIds !== undefined + ? new Set(input.controlIds) + : undefined; + const pruned = + nextIds && input.descriptionInstructions + ? pruneDescriptionInstructions( + input.descriptionInstructions, + nextIds, + ) + : input.descriptionInstructions; + data.descriptionInstructions = + pruned === null ? PrismaNs.DbNull : pruned; + } else if (input.controlIds !== undefined) { + // Sequence-only update: keep instructions but drop orphans. + const pruned = pruneDescriptionInstructions( + instructionsDto(c.descriptionInstructions), + new Set(input.controlIds), + ); + if ( + JSON.stringify(pruned) !== + JSON.stringify(c.descriptionInstructions) + ) { + data.descriptionInstructions = + pruned === null ? PrismaNs.DbNull : pruned; + } + } // Table writes + course.upserted journal entry commit together. await ctx.db.$transaction(async (tx) => { await tx.course.update({ where: { id: c.id }, data }); diff --git a/packages/api/src/routers/courseMap.ts b/packages/api/src/routers/courseMap.ts index 65f6f6c..0ae5c6b 100644 --- a/packages/api/src/routers/courseMap.ts +++ b/packages/api/src/routers/courseMap.ts @@ -9,6 +9,7 @@ import { type CourseMapOverrides, type CourseOverlayLeg, type DescriptionRow, + type DescriptionSheetHeader, type MapPoint, type MapWindow, } from "@oxygen/shared"; @@ -61,6 +62,8 @@ export interface CourseMapListItem { >; legs: CourseOverlayLeg[]; descriptionRows: DescriptionRow[]; + /** IOF 3-row header for course maps; null → single title row. */ + descriptionHeader: DescriptionSheetHeader | null; resolved: { document: CourseMapDocument; window: MapWindow } | null; validation: { valid: boolean; @@ -100,7 +103,7 @@ function notFound(): never { export const courseMapRouter = router({ list: coursesViewProcedure.query(async ({ ctx }): Promise => { - const [rows, mapInfo] = await Promise.all([ + const [rows, mapInfo, eventRow] = await Promise.all([ ctx.db.courseMap.findMany({ where: { eventId: ctx.event.id }, include: { @@ -122,6 +125,10 @@ export const courseMapRouter = router({ ], }), getBaseMapInfoOrNull(ctx.db, ctx.event.id), + ctx.db.event.findUnique({ + where: { id: ctx.event.id }, + select: { name: true, date: true }, + }), ]); const controlsForAll = await ctx.db.control.findMany({ @@ -170,9 +177,11 @@ export const courseMapRouter = router({ controls: row.course.courseControls.map( ({ control }) => control, ), + descriptionInstructions: row.course.descriptionInstructions, } : null, allControls: controlsForAll, + ...(eventRow ? { event: eventRow } : {}), windowCenter: row.windowCenter, mapScale: mapInfo?.scale ?? null, meridianTiltDeg: mapInfo?.meridianTiltDeg ?? null, @@ -188,6 +197,7 @@ export const courseMapRouter = router({ controls: layout.controls, legs: layout.legs, descriptionRows: layout.descriptionRows, + descriptionHeader: layout.descriptionHeader, validation: { valid: true, issues: [] }, }; }); @@ -201,15 +211,16 @@ export const courseMapRouter = router({ window: row.resolved.window, windows: windowsByCourse.get(row.courseId ?? "all_controls"), variants: [{ key: "", controls: row.controls }], - descriptionRowCount: row.controls.filter( - (control) => control.type === "control", - ).length, + descriptionRowCount: row.descriptionRows.length, + descriptionHeaderRows: row.descriptionHeader ? 3 : 1, }) : row.validation; const controls = "controls" in row ? row.controls : []; const legs = "legs" in row ? row.legs : []; const descriptionRows = "descriptionRows" in row ? row.descriptionRows : []; + const descriptionHeader = + "descriptionHeader" in row ? row.descriptionHeader : null; const resolved = "resolved" in row ? row.resolved : null; return { id: row.id, @@ -237,6 +248,7 @@ export const courseMapRouter = router({ controls, legs, descriptionRows, + descriptionHeader, resolved, validation, }; diff --git a/packages/api/src/routers/mapTemplate.ts b/packages/api/src/routers/mapTemplate.ts index a4613ee..b8f19ab 100644 --- a/packages/api/src/routers/mapTemplate.ts +++ b/packages/api/src/routers/mapTemplate.ts @@ -134,6 +134,7 @@ export const mapTemplateRouter = router({ geometry: course.geometry, classes: course.classes, controls: course.courseControls.map(({ control }) => control), + descriptionInstructions: course.descriptionInstructions, } : null, mapScale: mapInfo?.scale ?? null, diff --git a/packages/shared/src/__tests__/course-maps.test.ts b/packages/shared/src/__tests__/course-maps.test.ts index 29218b4..16cbc80 100644 --- a/packages/shared/src/__tests__/course-maps.test.ts +++ b/packages/shared/src/__tests__/course-maps.test.ts @@ -284,10 +284,65 @@ describe("course map SVG generators", () => { }); expect(svg).toContain("A & B"); expect(svg).toContain('viewBox="-100 -100 200 200"'); - expect(svg).toContain("1.5m"); + expect(svg).toContain("1.5"); expect(svg).toContain(">31"); }); + it("draws the IOF header bold, boxes the controls with thick rules and wide finish rows", () => { + const rows = [ + { kind: "start" as const, code: "", symbolKey: "start" }, + ...[31, 32, 33, 34].map((code, i) => ({ + kind: "control" as const, + sequence: i + 1, + code: String(code), + })), + { kind: "finish" as const, code: "", symbolKey: "14.3", lengthM: 120 }, + ]; + const svg = renderDescriptionBlockSvg({ + x: 0, + y: 0, + cellSizeMm: 10, + title: "ignored", + header: { + eventName: "Spring Cup", + classNames: "H40, D40", + courseName: "A", + lengthKm: "4.20 km", + climbM: "85 m", + }, + rows, + symbolResolver: (key) => + key === "start" + ? '' + : key === "14.3" + ? '' + : null, + }); + // Header rows are bold and every header rule is thick (y=10, 20, 30). + expect(svg).toContain('font-weight="bold" text-anchor="middle">Spring Cup'); + expect(svg).toContain('font-weight="bold" text-anchor="middle">H40, D40'); + for (const yy of [10, 20, 30]) { + expect(svg).toContain(`y1="${yy}" x2="80" y2="${yy}" stroke="#000000" stroke-width="0.35"`); + } + // Rows: start 30–40, controls 1–4 at 40–80, finish 80–90. Thick under + // start (y=40), after the third control (y=70) and above finish + // (y=80); thin between the other controls (y=50, 60). + expect(svg).toContain('y1="40" x2="80" y2="40" stroke="#000000" stroke-width="0.35"'); + expect(svg).toContain('y1="70" x2="80" y2="70" stroke="#000000" stroke-width="0.35"'); + expect(svg).toContain('y1="80" x2="80" y2="80" stroke="#000000" stroke-width="0.35"'); + expect(svg).toContain('y1="50" x2="80" y2="50" stroke="#000000" stroke-width="0.15"'); + expect(svg).toContain('y1="60" x2="80" y2="60" stroke="#000000" stroke-width="0.15"'); + // Column groups A B C | D E F | G H: thick verticals at x=30 and 60 in + // a control row (y 40–50), thin at x=10. + expect(svg).toContain('120 m"); + expect(svg).not.toContain(">ignored<"); + }); + it("renders page and map anchored objects in layer order", () => { const svg = renderMapObjectsSvg({ objects: [ diff --git a/packages/shared/src/__tests__/description-rows.test.ts b/packages/shared/src/__tests__/description-rows.test.ts new file mode 100644 index 0000000..3074a64 --- /dev/null +++ b/packages/shared/src/__tests__/description-rows.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + buildDescriptionSheet, + hasThickRuleBelow, + pruneDescriptionInstructions, +} from "../course-maps/description-rows.js"; + +describe("buildDescriptionSheet", () => { + it("emits the 3-row header, start, controls, default finish", () => { + const sheet = buildDescriptionSheet({ + eventName: "Test Event", + classNames: ["H21", "D21"], + courseName: "A", + lengthM: 4200, + climbM: 85, + controls: [ + { id: 31, code: "31", description: { d: "2.004" } }, + { id: 32, code: "32" }, + ], + }); + expect(sheet.header).toEqual({ + eventName: "Test Event", + classNames: "H21, D21", + courseName: "A", + lengthKm: "4.20 km", + climbM: "85 m", + }); + expect(sheet.rows.map((r) => r.kind)).toEqual([ + "start", + "control", + "control", + "finish", + ]); + expect(sheet.rows[1]).toMatchObject({ + kind: "control", + sequence: 1, + code: "31", + description: { d: "2.004" }, + }); + expect(sheet.rows[3]).toMatchObject({ + kind: "finish", + symbolKey: "14.3", + }); + }); + + it("inserts specials after start and after a named control", () => { + const sheet = buildDescriptionSheet({ + eventName: "E", + classNames: [], + courseName: "B", + lengthM: 0, + climbM: 0, + controls: [ + { id: 31, code: "31" }, + { id: 32, code: "32" }, + ], + instructions: { + specials: [ + { afterControlId: null, kind: "13.1", lengthM: 60 }, + { afterControlId: 31, kind: "13.2", lengthM: 120 }, + ], + finish: { kind: "14.1", lengthM: 150 }, + }, + }); + expect(sheet.rows.map((r) => [r.kind, r.symbolKey ?? r.code])).toEqual([ + ["start", "start"], + ["special", "13.1"], + ["control", "31"], + ["special", "13.2"], + ["control", "32"], + ["finish", "14.1"], + ]); + expect(sheet.rows[1].lengthM).toBe(60); + expect(sheet.rows[5].lengthM).toBe(150); + }); + + it("drops specials whose afterControlId is not on the course", () => { + const sheet = buildDescriptionSheet({ + eventName: "E", + classNames: [], + courseName: "C", + lengthM: 1000, + climbM: 0, + controls: [{ id: 31, code: "31" }], + instructions: { + specials: [ + { afterControlId: 99, kind: "13.1" }, + { afterControlId: 31, kind: "13.5" }, + ], + }, + }); + // 99 is not filtered by the builder itself — prune handles that at + // write time. The builder only emits specials whose after id matches + // a control in the input list (or null). + expect(sheet.rows.filter((r) => r.kind === "special")).toHaveLength(1); + expect(sheet.rows.find((r) => r.kind === "special")?.symbolKey).toBe("13.5"); + }); + + it("uses the measured last-control → finish distance when no explicit length is set", () => { + const base = { + eventName: "E", + classNames: [], + courseName: "D", + lengthM: 1000, + climbM: 0, + controls: [{ id: 31, code: "31" }], + }; + const measured = buildDescriptionSheet({ ...base, finishLengthM: 148.4 }); + expect(measured.rows.at(-1)).toMatchObject({ kind: "finish", lengthM: 148 }); + + // An explicit finish length always wins over the measured one. + const explicit = buildDescriptionSheet({ + ...base, + finishLengthM: 148.4, + instructions: { finish: { kind: "14.1", lengthM: 200 } }, + }); + expect(explicit.rows.at(-1)).toMatchObject({ symbolKey: "14.1", lengthM: 200 }); + + // Nothing measured and nothing set → no length text. + const none = buildDescriptionSheet({ ...base, finishLengthM: 0 }); + expect(none.rows.at(-1)?.lengthM).toBeUndefined(); + }); + + it("draws thick rules under start, after every third control, around specials and above finish", () => { + const sheet = buildDescriptionSheet({ + eventName: "E", + classNames: [], + courseName: "F", + lengthM: 0, + climbM: 0, + controls: [31, 32, 33, 34, 35, 36, 37, 38].map((code) => ({ id: code, code: String(code) })), + instructions: { specials: [{ afterControlId: 34, kind: "13.1" }] }, + }); + // start, 1, 2, 3, 4, special, 5, 6, 7, 8, finish + const thick = sheet.rows.map((_, i) => hasThickRuleBelow(sheet.rows, i)); + expect(thick).toEqual([ + true, // under start + false, // 1 + false, // 2 + true, // 3 — every third description + true, // 4 — above the special + true, // special — below it + false, // 5 + true, // 6 — every third + false, // 7 + true, // 8 — above finish + false, // finish (bottom border) + ]); + }); +}); + +describe("pruneDescriptionInstructions", () => { + it("keeps finish and after-start specials, drops orphans", () => { + const pruned = pruneDescriptionInstructions( + { + specials: [ + { afterControlId: null, kind: "13.1" }, + { afterControlId: 31, kind: "13.2" }, + { afterControlId: 99, kind: "13.3" }, + ], + finish: { kind: "14.2", lengthM: 80 }, + }, + new Set([31]), + ); + expect(pruned).toEqual({ + specials: [ + { afterControlId: null, kind: "13.1" }, + { afterControlId: 31, kind: "13.2" }, + ], + finish: { kind: "14.2", lengthM: 80 }, + }); + }); + + it("returns null when everything is gone", () => { + expect( + pruneDescriptionInstructions( + { specials: [{ afterControlId: 99, kind: "13.1" }] }, + new Set([31]), + ), + ).toBeNull(); + }); +}); diff --git a/packages/shared/src/course-maps/description-rows.ts b/packages/shared/src/course-maps/description-rows.ts new file mode 100644 index 0000000..41096de --- /dev/null +++ b/packages/shared/src/course-maps/description-rows.ts @@ -0,0 +1,205 @@ +/** + * Build the IOF control-description sheet row model for a single course. + * + * Layout (matches the printed sheet / SOFT example): + * header 1 event name (full width) + * header 2 class names (full width) + * header 3 course name (3) · length km (3) · climb m (2) + * start triangle in A, C–H from the start control's description + * controls A=seq, B=code, C–H from each control + * specials full-width 13.x symbol (+ optional length) inserted after a control + * finish full-width 14.x symbol (+ optional length) + * + * Union / all-controls sheets keep a single title row and skip this + * builder — callers use the simpler path in MapViewer / description-svg. + */ + +import type { + ControlDescription, + CourseDescriptionInstructions, +} from "../types.js"; + +export type DescriptionSheetRowKind = + | "start" + | "control" + | "special" + | "finish"; + +export interface DescriptionSheetRow { + kind: DescriptionSheetRowKind; + /** Sequence number for control rows (1-based); omitted for others. */ + sequence?: number; + /** Punch code (control) or empty (start / special / finish). */ + code: string; + description?: ControlDescription | null; + /** IOF key for special/finish full-width symbols (e.g. "13.1", "14.3"). */ + symbolKey?: string; + /** Optional length (metres) drawn in the centre of a special/finish row. */ + lengthM?: number; +} + +export interface DescriptionSheetHeader { + eventName: string; + classNames: string; + courseName: string; + /** Pre-formatted, e.g. "4.2 km". */ + lengthKm: string; + /** Pre-formatted, e.g. "85 m". */ + climbM: string; +} + +export interface DescriptionSheetModel { + header: DescriptionSheetHeader; + rows: DescriptionSheetRow[]; +} + +export interface BuildDescriptionSheetInput { + eventName: string; + classNames: string[]; + courseName: string; + lengthM: number; + climbM: number; + /** Start control description (optional — triangle always drawn). */ + startDescription?: ControlDescription | null; + controls: Array<{ + /** Public control id — used to place specials after this control. */ + id: number; + code: string; + description?: ControlDescription | null; + }>; + instructions?: CourseDescriptionInstructions | null; + /** + * Measured last-control → finish distance in metres (from the map + * geometry). Used for the finish row when the course instructions do + * not carry an explicit `finish.lengthM`. + */ + finishLengthM?: number | null; +} + +/** + * IOF control description layout: "A thicker horizontal line should be + * used after every third description and on either side of any special + * instruction." Plus the header block, the start row and the finish row + * are boxed in by thick rules. Returns true when the line *below* + * `rows[index]` should be drawn thick: + * - under the start row, + * - under every third control (sequence 3, 6, 9, …), + * - above and below a special-instruction row, + * - above the finish row. + */ +export function hasThickRuleBelow( + rows: readonly DescriptionSheetRow[], + index: number, +): boolean { + const row = rows[index]; + if (!row) return false; + if (row.kind === "start" || row.kind === "special") return true; + if (row.kind === "control" && row.sequence != null && row.sequence % 3 === 0) { + return true; + } + const next = rows[index + 1]?.kind; + return next === "special" || next === "finish"; +} + +/** + * Column groups of the IOF sheet: A B C | D E F | G H. A thick vertical + * rule follows these 0-based column indices (i.e. after the 3rd and 6th + * cell). + */ +export const DESCRIPTION_THICK_COLUMNS: readonly number[] = [3, 6]; + +function formatKm(lengthM: number): string { + if (!Number.isFinite(lengthM) || lengthM <= 0) return ""; + const km = lengthM / 1000; + return `${km.toFixed(km >= 10 ? 1 : 2)} km`; +} + +function formatClimb(climbM: number): string { + if (!Number.isFinite(climbM) || climbM <= 0) return ""; + return `${Math.round(climbM)} m`; +} + +/** + * Build the ordered sheet rows for one course. Specials whose + * `afterControlId` is not in the sequence are dropped. + */ +export function buildDescriptionSheet( + input: BuildDescriptionSheetInput, +): DescriptionSheetModel { + const header: DescriptionSheetHeader = { + eventName: input.eventName, + classNames: input.classNames.filter(Boolean).join(", "), + courseName: input.courseName, + lengthKm: formatKm(input.lengthM), + climbM: formatClimb(input.climbM), + }; + + const rows: DescriptionSheetRow[] = []; + const specials = input.instructions?.specials ?? []; + + const afterStart = specials.filter((s) => s.afterControlId == null); + rows.push({ + kind: "start", + code: "", + description: input.startDescription ?? null, + symbolKey: "start", + }); + for (const s of afterStart) { + rows.push({ + kind: "special", + code: "", + symbolKey: s.kind, + lengthM: s.lengthM, + }); + } + + input.controls.forEach((c, i) => { + rows.push({ + kind: "control", + sequence: i + 1, + code: c.code, + description: c.description ?? null, + }); + for (const s of specials.filter((sp) => sp.afterControlId === c.id)) { + rows.push({ + kind: "special", + code: "", + symbolKey: s.kind, + lengthM: s.lengthM, + }); + } + }); + + const finish = input.instructions?.finish ?? { kind: "14.3" }; + const measured = + input.finishLengthM != null && + Number.isFinite(input.finishLengthM) && + input.finishLengthM > 0 + ? Math.round(input.finishLengthM) + : undefined; + rows.push({ + kind: "finish", + code: "", + symbolKey: finish.kind, + lengthM: finish.lengthM ?? measured, + }); + + return { header, rows }; +} + +/** Drop specials that reference a control no longer on the course. */ +export function pruneDescriptionInstructions( + instructions: CourseDescriptionInstructions | null | undefined, + controlIds: ReadonlySet, +): CourseDescriptionInstructions | null { + if (!instructions) return null; + const specials = (instructions.specials ?? []).filter( + (s) => s.afterControlId == null || controlIds.has(s.afterControlId), + ); + const finish = instructions.finish; + if (specials.length === 0 && !finish) return null; + return { + ...(specials.length > 0 ? { specials } : {}), + ...(finish ? { finish } : {}), + }; +} diff --git a/packages/shared/src/course-maps/description-svg.ts b/packages/shared/src/course-maps/description-svg.ts index 81d4e43..adda591 100644 --- a/packages/shared/src/course-maps/description-svg.ts +++ b/packages/shared/src/course-maps/description-svg.ts @@ -1,5 +1,15 @@ import type { ControlDescription } from "../types.js"; import { escapeSvgText } from "./text.js"; +import { + descriptionCells, + ocadDescriptionCodeToIof, + type DescriptionCell, +} from "./iof-symbols.js"; +import { + DESCRIPTION_THICK_COLUMNS, + hasThickRuleBelow, + type DescriptionSheetRow, +} from "./description-rows.js"; export type IofSymbolResolver = (iofKey: string) => string | null | undefined; @@ -7,6 +17,14 @@ export interface DescriptionRow { sequence?: number; code: string; description?: ControlDescription | null; + /** + * Row kind from `description-rows.ts`. Absent / "control" draws a + * normal A–H row; "start" draws the triangle in A; "special" and + * "finish" draw `symbolKey` across the full width with `lengthM`. + */ + kind?: "start" | "control" | "special" | "finish"; + symbolKey?: string; + lengthM?: number; } export interface RenderDescriptionBlockOptions { @@ -17,66 +35,38 @@ export interface RenderDescriptionBlockOptions { rows: DescriptionRow[]; symbolResolver: IofSymbolResolver; color?: string; + /** + * When set, draw the full IOF header (event / classes / course·length·climb) + * instead of a single title row. `title` is ignored in that mode; use + * `header` fields instead. Special / finish rows can be mixed into + * `rows` via `kind: "special" | "finish" | "start"` on an extended row + * type — see `description-rows.ts`. + */ + header?: { + eventName: string; + classNames: string; + courseName: string; + lengthKm: string; + climbM: string; + }; } -const COMPASS = ["", "N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; -const CARDINAL = new Set(["N", "E", "S", "W"]); +export { ocadDescriptionCodeToIof }; -export function ocadDescriptionCodeToIof( - column: "c" | "d" | "f" | "g", - code: string, -): string | null { - const [group, rawSub] = code.split("."); - if (!rawSub) return null; - const sub = Number.parseInt(rawSub, 10); - if (!Number.isFinite(sub)) return null; - if (column === "d") return `${Number.parseInt(group, 10)}.${sub}`; - if (column === "f") { - const mapped: Record = { - "10.001": "10.1", - "10.002": "10.2", - "11.001": "11.7", - }; - return mapped[code] ?? `${Number.parseInt(group, 10)}.${sub}`; - } - if (column === "c") { - if (sub === 3 || sub === 300) return "0.3"; - if (sub === 4 || sub === 400) return "0.4"; - if (sub === 5 || sub === 500) return "0.5"; - const direction = COMPASS[sub >= 100 ? sub % 10 : sub]; - if (!direction) return null; - return `${CARDINAL.has(direction) ? "0.1" : "0.2"}${direction}`; +function renderCellContent( + x: number, + y: number, + size: number, + cell: DescriptionCell | null, + color: string, +): string { + if (!cell) return ""; + if (cell.kind === "text") { + return `${escapeSvgText(cell.text)}`; } - - const nonDirectional: Record = { - 8: "11.9", - 9: "11.10", - 10: "11.11", - 11: "11.13", - 13: "11.12", - 14: "11.15", - }; - if (sub < 100) return nonDirectional[sub] ?? null; - const direction = COMPASS[sub % 10]; - if (!direction) return null; - const rangeBase: Record = { - 12: "11.14", - 14: "11.14", - 15: "11.5", - 16: "11.6", - 17: "11.8", - }; - const typeBase: Record = { - 1: "11.1", - 2: "11.2", - 3: "11.3", - 4: "11.4", - 5: "11.5", - 6: "11.6", - 7: "11.8", - }; - const base = rangeBase[Math.floor(sub / 10)] ?? typeBase[Math.floor(sub / 100)]; - return base ? `${base}${direction}` : null; + const inset = size * 0.1; + const colored = cell.svg.replace(/ stroke-width="null"/g, ""); + return `${colored}`; } function renderCellSymbol( @@ -101,8 +91,12 @@ function renderCellSymbol( export function descriptionBlockSize( rowCount: number, cellSizeMm: number, + headerRows = 1, ): { width: number; height: number } { - return { width: cellSizeMm * 8, height: cellSizeMm * (rowCount + 1) }; + return { + width: cellSizeMm * 8, + height: cellSizeMm * (rowCount + headerRows), + }; } export function renderDescriptionBlockSvg( @@ -110,62 +104,126 @@ export function renderDescriptionBlockSvg( ): string { const { x, y, cellSizeMm: cell, rows } = options; const color = options.color ?? "#000000"; - const size = descriptionBlockSize(rows.length, cell); + const headerRows = options.header ? 3 : 1; + const size = descriptionBlockSize(rows.length, cell, headerRows); + // IOF sheet rules: thick outer border and header cells, thick rule + // under the start row and above the finish row (so the control rows + // are boxed in), thick verticals after columns C and F (A B C | D E F + // | G H). Everything else thin. Header text is bold throughout. + const THIN = 0.15; + const THICK = 0.35; const parts = [ ``, - ``, - ``, - `${escapeSvgText(options.title)}`, + ``, ]; + if (options.header) { + const { eventName, classNames, courseName, lengthKm, climbM } = options.header; + // Row 1: event name (full width) + parts.push( + ``, + `${escapeSvgText(eventName)}`, + ); + // Row 2: class names (full width) + parts.push( + ``, + `${escapeSvgText(classNames)}`, + ); + // Row 3: course (3) · length (3) · climb (2) + parts.push( + ``, + ``, + ``, + `${escapeSvgText(courseName)}`, + `${escapeSvgText(lengthKm)}`, + `${escapeSvgText(climbM)}`, + ); + } else { + parts.push( + ``, + `${escapeSvgText(options.title)}`, + ); + } for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { const row = rows[rowIndex]; - const top = y + cell * (rowIndex + 1); - for (let column = 1; column < 8; column += 1) { - const lineX = x + cell * column; - parts.push( - ``, - ); + const top = y + cell * (rowIndex + headerRows); + const wide = row.kind === "special" || row.kind === "finish"; + if (!wide) { + for (let column = 1; column < 8; column += 1) { + const lineX = x + cell * column; + const thick = DESCRIPTION_THICK_COLUMNS.includes(column); + parts.push( + ``, + ); + } } if (rowIndex < rows.length - 1) { + const thick = hasThickRuleBelow(rows as DescriptionSheetRow[], rowIndex); parts.push( - ``, + ``, ); } const textY = top + cell * 0.67; const textSize = cell * 0.43; - parts.push( - `${row.sequence ?? ""}`, - `${escapeSvgText(row.code)}`, - ); - const description = row.description; - if (!description) continue; - const symbolColumns: Array<["c" | "d" | "f" | "g", number]> = [ - ["c", 2], - ["d", 3], - ["f", 5], - ["g", 6], - ]; - for (const [key, column] of symbolColumns) { - const value = description[key]; - parts.push( - renderCellSymbol( - x + cell * column, - top, - cell, - value ? ocadDescriptionCodeToIof(key, value) : null, - options.symbolResolver, - color, - ), - ); + if (wide) { + // Full-width 13.x / 14.x symbol with the optional length centred. + const key = row.symbolKey; + const fragment = key ? options.symbolResolver(key) : null; + if (fragment) { + const colored = fragment + .replace(/stroke="black"/g, `stroke="${color}"`) + .replace(/fill="black"/g, `fill="${color}"`) + .replace(/ stroke-width="null"/g, ""); + const inset = cell * 0.1; + parts.push( + `${colored}`, + ); + } + if (row.lengthM != null && row.lengthM > 0) { + parts.push( + `${Math.round(row.lengthM)} m`, + ); + } + continue; } - if (description.s) { + + if (row.kind === "start") { + parts.push(renderCellSymbol(x, top, cell, "start", options.symbolResolver, color)); + } else { parts.push( - `${escapeSvgText(description.s.replace(",", ".") + "m")}`, + `${row.sequence ?? ""}`, + `${escapeSvgText(row.code)}`, ); } + + const description = row.description; + if (!description) continue; + const cells = descriptionCells(description, color); + const order: Array<[keyof typeof cells, number]> = [ + ["C", 2], + ["D", 3], + ["E", 4], + ["F", 5], + ["G", 6], + ["H", 7], + ]; + for (const [key, column] of order) { + parts.push(renderCellContent(x + cell * column, top, cell, cells[key], color)); + } } parts.push(""); return parts.join(""); } + +/** @deprecated Kept for callers that still resolve a single key via a resolver. */ +export function renderLegacyCellSymbol( + x: number, + y: number, + size: number, + key: string | null, + resolver: IofSymbolResolver, + color: string, +): string { + return renderCellSymbol(x, y, size, key, resolver, color); +} diff --git a/packages/shared/src/course-maps/index.ts b/packages/shared/src/course-maps/index.ts index 4f2b18c..301025d 100644 --- a/packages/shared/src/course-maps/index.ts +++ b/packages/shared/src/course-maps/index.ts @@ -6,6 +6,7 @@ export * from "./course-overlay-svg.js"; export * from "./overlay-geometry.js"; export * from "./control-label-placement.js"; export * from "./description-svg.js"; +export * from "./description-rows.js"; export * from "./validate.js"; export * from "./iof-symbols.js"; export * from "./iof-symbol-meta.js"; diff --git a/packages/shared/src/course-maps/iof-symbol-meta.ts b/packages/shared/src/course-maps/iof-symbol-meta.ts index ffc8303..ab0a1b0 100644 --- a/packages/shared/src/course-maps/iof-symbol-meta.ts +++ b/packages/shared/src/course-maps/iof-symbol-meta.ts @@ -3,8 +3,8 @@ // svg-control-descriptions package (extracted from Purple Pen). export interface IofSymbolMeta { - /** Description sheet column: C, D, F or G. */ - kind: "C" | "D" | "F" | "G"; + /** Description sheet column / row kind. */ + kind: "A" | "C" | "D" | "E" | "F" | "G" | "H" | "W" | "X" | "Y" | "Z" | "V"; en: string; sv: string; } @@ -94,6 +94,17 @@ export const IOF_SYMBOL_META: Record = { "5.26": { kind: "D", en: "Railway", sv: "Järnväg" }, "6.1": { kind: "D", en: "Special item", sv: "Speciellt föremål" }, "6.2": { kind: "D", en: "Special item", sv: "Speciellt föremål" }, + "8.1": { kind: "E", en: "Low", sv: "Låg" }, + "8.2": { kind: "E", en: "Shallow", sv: "Flack" }, + "8.3": { kind: "E", en: "Deep", sv: "Djup" }, + "8.4": { kind: "E", en: "Overgrown", sv: "Bevuxen" }, + "8.5": { kind: "E", en: "Open", sv: "Öppen" }, + "8.6": { kind: "E", en: "Rocky, stony", sv: "Stenig" }, + "8.7": { kind: "E", en: "Marshy", sv: "Sank" }, + "8.8": { kind: "E", en: "Sandy", sv: "Sandig" }, + "8.9": { kind: "E", en: "Needle-leaved", sv: "Barrträd" }, + "8.10": { kind: "E", en: "Broad-leaved", sv: "Lövträd" }, + "8.11": { kind: "E", en: "Ruined", sv: "Raserad" }, "10.1": { kind: "F", en: "Crossing", sv: "Korsning" }, "10.2": { kind: "F", en: "Junction", sv: "Förgrening" }, "11.1E": { kind: "G", en: "East side", sv: "Östra sidan" }, @@ -167,6 +178,21 @@ export const IOF_SYMBOL_META: Record = { "11.14SW": { kind: "G", en: "South-west foot", sv: "Sydvästra foten" }, "11.14W": { kind: "G", en: "West foot", sv: "Västra foten" }, "11.15": { kind: "G", en: "Between", sv: "Mellan" }, + "12.1": { kind: "H", en: "First aid post", sv: "Sjukvård" }, + "12.2": { kind: "H", en: "Refreshment point", sv: "Vätska" }, + "12.4": { kind: "H", en: "Control check", sv: "Bemannad kontroll" }, + "13.1": { kind: "W", en: "Taped route away from control", sv: "Snitsel bort från kontroll" }, + "13.2": { kind: "W", en: "Taped route between controls", sv: "Snitsel mellan kontroller" }, + "13.3": { kind: "X", en: "Mandatory crossing point or points", sv: "Obligatorisk passerpunkt" }, + "13.4": { kind: "X", en: "Mandatory passage", sv: "Obligatorisk passage" }, + "13.5": { kind: "W", en: "Map exchange", sv: "Kartbyte" }, + "13.5control": { kind: "Y", en: "Map exchange at a control", sv: "Kartbyte vid kontroll" }, + "13.6": { kind: "V", en: "Map issue point", sv: "Kartutdelningsplats" }, + "14.1": { kind: "Z", en: "Taped route to finish", sv: "Snitsel till mål" }, + "14.2": { kind: "Z", en: "Navigate to finish funnel", sv: "Uppfångande snitsel till mål" }, + "14.3": { kind: "Z", en: "Navigate to finish (no tapes)", sv: "Ingen snitsel till mål" }, + "15.6": { kind: "Y", en: "Map flip", sv: "Kartvändning" }, + "start": { kind: "A", en: "Start", sv: "Start" }, }; /** Localized symbol name; falls back to English for unknown languages. */ diff --git a/packages/shared/src/course-maps/iof-symbols.ts b/packages/shared/src/course-maps/iof-symbols.ts index 50d9f7f..b5a10f2 100644 --- a/packages/shared/src/course-maps/iof-symbols.ts +++ b/packages/shared/src/course-maps/iof-symbols.ts @@ -296,35 +296,136 @@ export function ocadFtoIof(code: string): string | null { } /** - * Get the IOF symbol SVG inner content for a given OCAD description, - * organized by IOF column (C, D, E, F, G). - * Returns an object with symbol SVG strings (already colored) for each column, - * or null for columns with no matching symbol. + * Column E: appearance (8.x) **or** a second feature (column-D family). + * Both use the same "grp.sub" → "grp.subInt" encoding. + */ +export function ocadEtoIof(code: string): string | null { + return ocadDtoIof(code); +} + +/** Column H: other information (12.x). */ +export function ocadHtoIof(code: string): string | null { + return ocadDtoIof(code); +} + +/** + * Unified OCAD→IOF key lookup for any description column. Prefer this + * over the per-column helpers when the column is dynamic. + */ +export function ocadDescriptionCodeToIof( + column: "c" | "d" | "e" | "f" | "g" | "h", + code: string, +): string | null { + switch (column) { + case "c": return ocadCtoIof(code); + case "d": return ocadDtoIof(code); + case "e": return ocadEtoIof(code); + case "f": return ocadFtoIof(code); + case "g": return ocadGtoIof(code); + case "h": return ocadHtoIof(code); + } +} + +export type DescriptionCell = + | { kind: "svg"; key: string; svg: string } + | { kind: "text"; text: string }; + +export type DescriptionCells = { + C: DescriptionCell | null; + D: DescriptionCell | null; + E: DescriptionCell | null; + F: DescriptionCell | null; + G: DescriptionCell | null; + H: DescriptionCell | null; +}; + +function colorizeSvg(svg: string, color: string): string { + return svg + .replace(/stroke="black"/g, `stroke="${color}"`) + .replace(/fill="black"/g, `fill="${color}"`); +} + +function svgCell(key: string | null, color: string): DescriptionCell | null { + if (!key || !(key in IOF_SYMBOLS)) return null; + return { kind: "svg", key, svg: colorizeSvg(IOF_SYMBOLS[key], color) }; +} + +/** + * Resolve an OCAD description into the six IOF sheet cells C–H. + * + * Layout (IOF 2018/2024): + * C which-of-similar · D feature · E appearance / 2nd feature · + * F dimensions OR combination · G flag location · H other info. + * + * Dimensions (`s`) draw in F as plain text (no unit suffix) when no + * combination symbol (`f`) is set — matching the printed sheet. The + * legacy helper `getDescriptionSymbols` used to put size in E with an + * "m" suffix; callers should migrate to this. + */ +export function descriptionCells( + desc: { + c?: string; + d?: string; + e?: string; + g?: string; + s?: string; + f?: string; + h?: string; + }, + color = "#c026d3", +): DescriptionCells { + const fKey = desc.f ? ocadFtoIof(desc.f) : null; + let F: DescriptionCell | null = svgCell(fKey, color); + if (!F && desc.s) { + F = { kind: "text", text: desc.s.replace(",", ".") }; + } + + return { + C: svgCell(desc.c ? ocadCtoIof(desc.c) : null, color), + D: svgCell(desc.d ? ocadDtoIof(desc.d) : null, color), + E: svgCell(desc.e ? ocadEtoIof(desc.e) : null, color), + F, + G: svgCell(desc.g ? ocadGtoIof(desc.g) : null, color), + H: svgCell(desc.h ? ocadHtoIof(desc.h) : null, color), + }; +} + +/** + * @deprecated Prefer `descriptionCells`. Kept for a few call sites that + * still expect the old colE-as-dimensions shape; maps F's text/svg into + * colF and leaves colE empty unless `e` is set. */ export function getDescriptionSymbols( - desc: { c?: string; d?: string; g?: string; s?: string; f?: string }, + desc: { + c?: string; + d?: string; + e?: string; + g?: string; + s?: string; + f?: string; + h?: string; + }, color = "#c026d3", ): { colC: string | null; colD: string | null; - colE: string | null; // dimensions text, not a symbol + colE: string | null; colF: string | null; colG: string | null; + colH: string | null; } { - const colorize = (svg: string) => - svg.replace(/stroke="black"/g, `stroke="${color}"`) - .replace(/fill="black"/g, `fill="${color}"`); - - const dKey = desc.d ? ocadDtoIof(desc.d) : null; - const cKey = desc.c ? ocadCtoIof(desc.c) : null; - const gKey = desc.g ? ocadGtoIof(desc.g) : null; - const fKey = desc.f ? ocadFtoIof(desc.f) : null; - + const cells = descriptionCells(desc, color); + const svgOrText = (cell: DescriptionCell | null): string | null => { + if (!cell) return null; + return cell.kind === "svg" ? cell.svg : cell.text; + }; return { - colC: cKey ? colorize(IOF_SYMBOLS[cKey]) : null, - colD: dKey ? colorize(IOF_SYMBOLS[dKey]) : null, - colE: desc.s ? desc.s.replace(",", ".") + "m" : null, - colF: fKey ? colorize(IOF_SYMBOLS[fKey]) : null, - colG: gKey ? colorize(IOF_SYMBOLS[gKey]) : null, + colC: svgOrText(cells.C), + colD: svgOrText(cells.D), + colE: svgOrText(cells.E), + colF: svgOrText(cells.F), + colG: svgOrText(cells.G), + colH: svgOrText(cells.H), }; } + diff --git a/packages/shared/src/course-maps/validate.ts b/packages/shared/src/course-maps/validate.ts index 4e35c1f..3dfbd8c 100644 --- a/packages/shared/src/course-maps/validate.ts +++ b/packages/shared/src/course-maps/validate.ts @@ -41,6 +41,8 @@ export interface ValidateCourseMapOptions { windows?: MapWindow[]; variants: CourseMapVariant[]; descriptionRowCount?: number; + /** Header rows above the description rows (1 title row, or 3 for the IOF header). */ + descriptionHeaderRows?: number; } export function printablePageRect(document: CourseMapDocument): MapRect { @@ -182,6 +184,7 @@ export function validateCourseMap( options.descriptionRowCount ?? Math.max(0, ...options.variants.map((variant) => variant.controls.length)), options.document.description.cellSizeMm, + options.descriptionHeaderRows ?? 1, ); if ( !rectInside( diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index cdef88e..10454b6 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -794,16 +794,49 @@ export interface ControlUnit { * codes to IOF symbol SVGs per column. */ export interface ControlDescription { - /** Column C: which of similar features (e.g. "0.208" = Middle). */ + /** Column C: which of similar features (e.g. "0.201" = Northern). */ c?: string; - /** Column D: control feature (e.g. "2.001" = Terrace). */ + /** Column D: control feature (e.g. "2.004" = Boulder). */ d?: string; - /** Column G: location of the flag (e.g. "11.143" = NE side). */ - g?: string; - /** Column E: appearance/dimensions text (e.g. "1,5" = 1.5 m). */ + /** + * Column E: appearance (8.x, e.g. "8.001" = Low) **or** the second + * feature of a crossing/junction (a column-D code like "5.001"). + */ + e?: string; + /** + * Free-text dimensions (e.g. "1,5" or "2x3"). Drawn in column F when + * no combination symbol (`f`) is set — IOF puts size in F, appearance + * in E. + */ s?: string; - /** Column F: combination / second feature. */ + /** Column F: combination — crossing / junction / bend (e.g. "10.001"). */ f?: string; + /** Column G: location of the flag (e.g. "11.101" = N side). */ + g?: string; + /** Column H: other information (12.x, e.g. "12.001" = First aid). */ + h?: string; +} + +/** + * Course-level description-sheet instructions that sit between / around + * control rows (taped routes, map exchange, finish variants). Anchored + * to public control ids so they drop out when a control leaves the + * sequence. + */ +export interface CourseDescriptionInstructions { + specials?: Array<{ + /** Public control id after which the row appears; null = after start. */ + afterControlId: number | null; + /** IOF special-instruction key, e.g. "13.1". */ + kind: string; + /** Optional length shown in the centre of the symbol, metres. */ + lengthM?: number; + }>; + finish?: { + /** IOF finish variant, e.g. "14.1" / "14.2" / "14.3". */ + kind: string; + lengthM?: number; + }; } /** Control summary (list view) */ @@ -851,6 +884,11 @@ export interface CourseSummary { */ startControlId: number | null; finishControlId: number | null; + /** + * Course-level description-sheet instructions. `afterControlId` uses + * the public control id (punch code / seq); null = after start. + */ + descriptionInstructions: CourseDescriptionInstructions | null; } /** Course detail with class usage */ diff --git a/packages/web/src/components/ControlDescriptionEditor.tsx b/packages/web/src/components/ControlDescriptionEditor.tsx index 94a4730..8d64f28 100644 --- a/packages/web/src/components/ControlDescriptionEditor.tsx +++ b/packages/web/src/components/ControlDescriptionEditor.tsx @@ -1,14 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { ControlDescription } from "@oxygen/shared"; -import { IOF_SYMBOLS, getDescriptionSymbols } from "../iof-symbols"; +import { IOF_SYMBOLS, descriptionCells } from "../iof-symbols"; import { iofSymbolName } from "../iof-symbol-meta"; import { C_OPTIONS, + COMPASS_DIRECTIONS, D_GROUPS, + E_OPTIONS, F_OPTIONS, G_DIRECTIONAL, G_PLAIN, + H_OPTIONS, ocadToIof, type DescriptionOption, } from "../lib/control-description-options"; @@ -16,14 +19,12 @@ import { /** * Modal editor for a control's IOF description (stored on the control * row in the OCAD text encoding). Symbol pickers per sheet column — - * C (which of similar), D (feature), E (dimensions, free text), - * F (combination), G (flag location) — with a live preview row. - * - * Editing writes canonical OCAD codes; untouched fields keep whatever - * encoding the OCD importer stored (both render identically). + * C (which of similar), D (feature), E (appearance / 2nd feature), + * F (dimensions + combination), G (flag location), H (other info) — + * with a live preview row and written symbol titles (mobile-friendly). */ -type SymbolField = "c" | "d" | "f" | "g"; +type SymbolField = "c" | "d" | "e" | "f" | "g" | "h"; interface Props { /** Punch code shown in the header and preview row. */ @@ -38,11 +39,13 @@ function SymbolButton({ selected, name, onClick, + showLabel, }: { option: DescriptionOption; selected: boolean; name: string; onClick: () => void; + showLabel?: boolean; }) { return ( ); } +function composeSummary( + desc: ControlDescription, + language: string, +): string { + const parts: string[] = []; + const push = (field: SymbolField, code?: string) => { + if (!code) return; + const iof = ocadToIof(field, code); + if (iof) parts.push(iofSymbolName(iof, language)); + }; + push("c", desc.c); + push("d", desc.d); + push("e", desc.e); + if (desc.s) parts.push(desc.s.replace(",", ".")); + push("f", desc.f); + push("g", desc.g); + push("h", desc.h); + return parts.join(" · "); +} + export function ControlDescriptionEditor({ controlCode, initial, onSave, onCancel }: Props) { const { t, i18n } = useTranslation("controls"); const [desc, setDesc] = useState(() => ({ ...(initial ?? {}) })); + const [showSecondFeature, setShowSecondFeature] = useState( + () => !!(initial?.e && !String(initial.e).startsWith("8.")), + ); + const [announce, setAnnounce] = useState(""); - // Swallow Escape before the editor page's global handler clears the - // map selection underneath the modal. useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") { @@ -90,8 +123,10 @@ export function ControlDescriptionEditor({ controlCode, initial, onSave, onCance () => ({ c: desc.c ? ocadToIof("c", desc.c) : null, d: desc.d ? ocadToIof("d", desc.d) : null, + e: desc.e ? ocadToIof("e", desc.e) : null, f: desc.f ? ocadToIof("f", desc.f) : null, g: desc.g ? ocadToIof("g", desc.g) : null, + h: desc.h ? ocadToIof("h", desc.h) : null, }), [desc], ); @@ -103,13 +138,25 @@ export function ControlDescriptionEditor({ controlCode, initial, onSave, onCance else next[field] = opt.ocad; return next; }); - }, []); + setAnnounce(iofSymbolName(opt.iof, i18n.language)); + }, [i18n.language]); - const preview = useMemo(() => getDescriptionSymbols(desc), [desc]); + const preview = useMemo(() => descriptionCells(desc), [desc]); + const summary = useMemo( + () => composeSummary(desc, i18n.language), + [desc, i18n.language], + ); const isEmpty = Object.values(desc).every((v) => !v); - const renderOptions = (field: SymbolField, options: DescriptionOption[]) => ( -
+ const cCompass = C_OPTIONS.slice(0, 8); + const cExtra = C_OPTIONS.slice(8); + + const renderOptions = ( + field: SymbolField, + options: DescriptionOption[], + { showLabel = false }: { showLabel?: boolean } = {}, + ) => ( +
{options.map((opt) => { const selected = selectedIof[field] === opt.iof; return ( @@ -118,6 +165,7 @@ export function ControlDescriptionEditor({ controlCode, initial, onSave, onCance option={opt} selected={selected} name={iofSymbolName(opt.iof, i18n.language)} + showLabel={showLabel} onClick={() => setField(field, opt, selected)} /> ); @@ -125,55 +173,114 @@ export function ControlDescriptionEditor({ controlCode, initial, onSave, onCance
); - const sectionTitle = (label: string) => ( -

- {label} + const sectionTitle = (label: string, selectedName?: string | null) => ( +

+ {label} + {selectedName && ( + + — {selectedName} + + )}

); + const selectedName = (field: SymbolField) => { + const key = selectedIof[field]; + return key ? iofSymbolName(key, i18n.language) : null; + }; + + // Column C compass grid: NW N NE / W · E / SW S SE matching map north-up. + const cByDir = new Map( + cCompass.map((o) => { + const dir = COMPASS_DIRECTIONS.find((d) => o.iof.endsWith(d)); + return [dir, o] as const; + }), + ); + const cGridOrder: Array = [ + "NW", "N", "NE", + "W", null, "E", + "SW", "S", "SE", + ]; + return (
{ if (e.target === e.currentTarget) onCancel(); }} >
- {/* Header + live preview row */} -
+

{t("descEditor.title", { code: controlCode })}

- {(["code", "colC", "colD", "colE", "colF", "colG"] as const).map((col) => ( + {(["code", "C", "D", "E", "F", "G", "H"] as const).map((col) => (
{col === "code" ? ( controlCode - ) : col === "colE" ? ( - {preview.colE} - ) : preview[col] ? ( - - ) : null} + ) : (() => { + const cell = preview[col]; + if (!cell) return null; + if (cell.kind === "text") { + return {cell.text}; + } + return ( + + ); + })()}
))}
+

+ {summary || {t("descEditor.emptySummary")}} +

+ {announce}
- {/* Pickers */} -
- {sectionTitle(t("descEditor.columnC"))} - {renderOptions("c", C_OPTIONS)} +
+ {sectionTitle(t("descEditor.columnC"), selectedName("c"))} +
+
+ {cGridOrder.map((dir, i) => { + if (!dir) { + return
; + } + const opt = cByDir.get(dir as never); + if (!opt) return null; + const selected = selectedIof.c === opt.iof; + return ( + setField("c", opt, selected)} + /> + ); + })} +
+ {renderOptions("c", cExtra, { showLabel: true })} +
- {sectionTitle(t("descEditor.columnD"))} -
+ {sectionTitle(t("descEditor.columnD"), selectedName("d"))} +
{D_GROUPS.map(({ group, options }) => (
@@ -184,7 +291,40 @@ export function ControlDescriptionEditor({ controlCode, initial, onSave, onCance ))}
- {sectionTitle(t("descEditor.columnE"))} + {sectionTitle(t("descEditor.columnE"), selectedName("e"))} +
+

{t("descEditor.appearanceHint")}

+ {renderOptions("e", E_OPTIONS, { showLabel: true })} + + {showSecondFeature && ( +
+

{t("descEditor.secondFeatureHint")}

+ {D_GROUPS.map(({ group, options }) => ( +
+
+ {t(`descEditor.dGroup${group}` as "descEditor.dGroup1")} +
+ {renderOptions("e", options)} +
+ ))} +
+ )} +
+ + {sectionTitle(t("descEditor.columnF"), selectedName("f") ?? (desc.s ? desc.s : null))} +
+

{t("descEditor.combinationHint")}

+ {renderOptions("f", F_OPTIONS, { showLabel: true })} +
- {sectionTitle(t("descEditor.columnF"))} - {renderOptions("f", F_OPTIONS)} - - {sectionTitle(t("descEditor.columnG"))} -
+ {sectionTitle(t("descEditor.columnG"), selectedName("g"))} +
{G_DIRECTIONAL.map(({ iofBase, byDirection }) => (
{renderOptions("g", byDirection)}
))} -
{renderOptions("g", G_PLAIN)}
+
{renderOptions("g", G_PLAIN, { showLabel: true })}
+
+ + {sectionTitle(t("descEditor.columnH"), selectedName("h"))} +
+ {renderOptions("h", H_OPTIONS, { showLabel: true })}
- {/* Footer */} -
+
)} + {/* Rotated map layer (tiles + overlay) — corrects map north offset */}
- {/* Base map tiles (composite half) */} ( -
{/* Editor contextual actions — unrotated HTML, anchored at the selection/phantom */} {editorMenu} {dragWarning} + {editor && editorDragPos && viewport && (() => { + const inner = mapMmToScreen(editorDragPos.x, editorDragPos.y); + if (!inner) return null; + const pos = innerToContainer(inner.x, inner.y); + return ( + + ); + })()} +
{/* Description sheet (not rotated) */} {descriptionSheet && ( @@ -2706,7 +2751,7 @@ function renderDescriptionSheet( cw: number, ch: number, /** The highlighted courses. 1 → sequence card; >1 → code-sorted union. */ - activeCourses?: Array>, + activeCourses?: Array>, /** All control overlays — used to resolve id → code/type when geometry is sparse. */ controlOverlays?: ControlOverlay[], /** @@ -2717,6 +2762,12 @@ function renderDescriptionSheet( allControlsMode = false, /** Localized title for the all-controls listing. */ allControlsTitle?: string, + sheetContext: { + /** Event name for header row 1 of a single-course sheet. */ + eventName?: string; + /** Map scale (1:N) — turns the last-control → finish leg into metres. */ + mapScale?: number | null; + } = {}, ): React.ReactNode | null { if (!courseGeometry?.features && !allControlsMode) return null; @@ -2770,16 +2821,95 @@ function renderDescriptionSheet( // UNION of all their controls, without sequence numbers (there is no // shared sequence, and the map keeps showing codes). No course → every // positioned control (`allControlsMode`) or all geometry codes. - type Row = { code: string; description?: unknown }; + type Row = { + code: string; + description?: unknown; + sequence?: number; + kind?: string; + symbolKey?: string; + lengthM?: number; + }; const rows: Row[] = []; const single = activeCourses?.length === 1 ? activeCourses[0] : null; const withSequence = single !== null; let title = ""; + let headerRows = 1; + let sheetHeader: { + eventName: string; + classNames: string; + courseName: string; + lengthKm: string; + climbM: string; + } | null = null; + if (single) { title = single.name; + // Prefer the full IOF sheet (start + specials + finish + 3-row header) + // when we have course metadata; otherwise fall back to control rows only. + const controlRows: Array<{ + id: number; + code: string; + description?: unknown; + }> = []; for (const cid of single.controls) { + const overlay = controlOverlays?.find((o) => o.id === cid); + if (overlay && overlay.type !== "Control") continue; const row = toRow(cid); - if (row) rows.push(row); + if (!row) continue; + const idNum = Number.parseInt(String(overlay?.code ?? cid), 10); + controlRows.push({ + id: Number.isFinite(idNum) ? idNum : controlRows.length + 1, + code: row.code, + description: row.description, + }); + } + const startOverlay = controlOverlays?.find( + (o) => o.type === "Start" && single.controls.includes(o.id), + ); + // Last control → finish in terrain metres, from the overlay positions + // (map mm) and the map scale. Only when the course actually ends at a + // finish control. + let finishLengthM: number | null = null; + const finishOverlay = controlOverlays?.find( + (o) => o.type === "Finish" && single.controls.includes(o.id), + ); + if (finishOverlay && sheetContext.mapScale) { + const lastId = [...single.controls] + .reverse() + .find((cid) => controlOverlays?.find((o) => o.id === cid)?.type === "Control"); + const last = lastId ? controlOverlays?.find((o) => o.id === lastId) : undefined; + if (last) { + const mm = Math.hypot(finishOverlay.x - last.x, finishOverlay.y - last.y); + finishLengthM = (mm * sheetContext.mapScale) / 1000; + } + } + const sheet = buildDescriptionSheet({ + // Course name stands in when the caller has no event name yet. + eventName: sheetContext.eventName || single.name, + classNames: single.classNames ?? [], + courseName: single.name, + lengthM: single.lengthM ?? 0, + climbM: single.climbM ?? 0, + startDescription: startOverlay?.description ?? null, + controls: controlRows.map((c) => ({ + id: c.id, + code: c.code, + description: c.description as never, + })), + instructions: single.descriptionInstructions ?? null, + finishLengthM, + }); + sheetHeader = sheet.header; + headerRows = 3; + for (const r of sheet.rows as DescriptionSheetRow[]) { + rows.push({ + code: r.code, + description: r.description, + sequence: r.sequence, + kind: r.kind, + symbolKey: r.symbolKey, + lengthM: r.lengthM, + }); } } else if (activeCourses && activeCourses.length > 1) { title = activeCourses.map((c) => c.name).join(" · "); @@ -2812,10 +2942,9 @@ function renderDescriptionSheet( if (rows.length === 0) return null; - // IOF standard: 8 columns (A=seq, B=code, C-G=description symbols, H=dimensions) + // IOF standard: 8 columns (A=seq, B=code, C–H=description symbols) const cellSize = Math.max(20, Math.min(36, 7 * symbolScale)); const cols = 8; - const headerRows = 1; // course name header const sheetW = cols * cellSize; const sheetY = 12; @@ -2846,40 +2975,109 @@ function renderDescriptionSheet( ); + // IOF sheet rules: thick outer border and header cells, thick rule + // under the start row and above the finish row (the control rows sit + // boxed in between), thick verticals after columns C and F + // (A B C | D E F | G H). Everything else thin. No shaded header — + // bold text and the rules do the separating. + const THIN = 0.5; + const THICK = 1.5; + const RULE = "#64748b"; elements.push( + fill="white" stroke={RULE} strokeWidth={THICK} rx={2} /> ); - // Header row: course name(s). Long multi-course titles shrink to fit. - elements.push( - - ); - if (title && bi === 0) { + if (sheetHeader) { + const h = sheetHeader; + if (bi === 0) { + const fs1 = Math.min(cellSize * 0.42, (sheetW - 8) / Math.max(1, h.eventName.length * 0.6)); + const fs2 = Math.min(cellSize * 0.38, (sheetW - 8) / Math.max(1, h.classNames.length * 0.6)); + elements.push( + + {h.eventName} + , + + {h.classNames} + , + , + , + + {h.courseName} + , + + {h.lengthKm} + , + + {h.climbM} + , + ); + } + } else if (title && bi === 0) { const titleFs = Math.min(cellSize * 0.5, (sheetW - 8) / (title.length * 0.62)); elements.push( + fontSize={titleFs} fill="#0f172a" fontWeight="bold" data-testid="desc-title"> {title} - + , ); } - // Grid lines - for (let r = 0; r <= totalRows; r++) { + // Alternate row shading, under the grid so the rules stay crisp. + for (let i = 0; i < blockRows.length; i++) { + const n = bi * rowsPerBlock + i; + if (n % 2 !== 1) continue; + const top = sheetY + (headerRows + i) * cellSize; elements.push( - + , ); } - for (let c = 0; c <= cols; c++) { + + // Horizontal rules: every header rule thick; below the rows thick only + // under start and above finish (evaluated against the whole sheet so a + // block split right before the finish row still gets its rule). + const allRows = rows as unknown as DescriptionSheetRow[]; + for (let r = 0; r <= totalRows; r++) { + const rowIdx = bi * rowsPerBlock + (r - headerRows - 1); // row whose bottom edge this is + const thick = + r <= headerRows || + (r > headerRows && r < totalRows && hasThickRuleBelow(allRows, rowIdx)); elements.push( - + headerRows && r < totalRows ? "desc-thick-rule" : undefined} /> ); } + // Column dividers per row — thick after C and F, none on full-width + // special / finish rows. + for (let i = 0; i < blockRows.length; i++) { + const kind = blockRows[i].kind; + if (kind === "special" || kind === "finish") continue; + const top = sheetY + (headerRows + i) * cellSize; + for (let c = 1; c < cols; c++) { + const thick = DESCRIPTION_THICK_COLUMNS.includes(c); + elements.push( + + ); + } + } renderDescriptionRows(elements, blockRows, bi * rowsPerBlock, withSequence, { sheetX, sheetY, sheetW, cellSize, headerRows, @@ -2900,10 +3098,17 @@ function renderDescriptionSheet( return {elements}; } -/** One block of description rows (code + IOF symbol cells). */ +/** One block of description rows (code + IOF symbol cells C–H). */ function renderDescriptionRows( elements: React.ReactNode[], - rows: Array<{ code: string; description?: unknown }>, + rows: Array<{ + code: string; + description?: unknown; + sequence?: number; + kind?: string; + symbolKey?: string; + lengthM?: number; + }>, /** Index of the first row within the whole sheet (column A numbering). */ rowOffset: number, withSequence: boolean, @@ -2911,68 +3116,121 @@ function renderDescriptionRows( ): void { const { sheetX, sheetY, sheetW, cellSize, headerRows } = geom; - // Control rows for (let i = 0; i < rows.length; i++) { const row = rows[i]; const code = row.code; - const desc = row.description; + const desc = row.description as + | { c?: string; d?: string; e?: string; g?: string; s?: string; f?: string; h?: string } + | undefined; const n = rowOffset + i; const ry = sheetY + (i + headerRows) * cellSize; const fs = cellSize * 0.45; - // Alternate row shading for readability — drawn first so the row - // content lands on top of it. - if (n % 2 === 1) { - elements.push( - - ); + // Special / finish rows: full-width symbol spanning columns A–H. + if (row.kind === "special" || row.kind === "finish" || row.kind === "start") { + const key = row.symbolKey ?? (row.kind === "start" ? "start" : undefined); + if (key && IOF_SYMBOLS[key]) { + // Start: triangle in column A, optional C–H description. + if (row.kind === "start") { + elements.push( + , + ); + if (desc) { + const cells = descriptionCells(desc, "#c026d3"); + const order = ["C", "D", "E", "F", "G", "H"] as const; + order.forEach((col, ci) => { + const cell = cells[col]; + if (!cell) return; + const sx = sheetX + (ci + 2) * cellSize; + if (cell.kind === "text") { + elements.push( + + {cell.text} + , + ); + } else { + elements.push( + , + ); + } + }); + } + continue; + } + // Special / finish: symbol drawn across the full width with optional length. + elements.push( + , + ); + if (row.lengthM != null && row.lengthM > 0) { + elements.push( + + {`${Math.round(row.lengthM)} m`} + , + ); + } + continue; + } } - // Column A: sequence number — only meaningful for a single course. - // The multi-course union card is code-keyed, so A stays empty. - if (withSequence) { + if (withSequence && row.sequence != null) { + elements.push( + + {row.sequence} + , + ); + } else if (withSequence && !row.kind) { elements.push( {n + 1} - + , ); } - // Column B: control code elements.push( {code} - + , ); - // Columns C-G: IOF description symbols (empty if no description) - const symbols = desc ? getDescriptionSymbols(desc, "#c026d3") : ({} as ReturnType); - const colKeys = ["colC", "colD", "colE", "colF", "colG"] as const; - for (let ci = 0; ci < colKeys.length; ci++) { - const content = symbols[colKeys[ci]]; - if (content) { - const sx = sheetX + (ci + 2) * cellSize; - if (colKeys[ci] === "colE") { - // colE is dimensions text (e.g. "3m"), render as SVG text - elements.push( - - {content} - - ); - } else { - // IOF symbol SVG — render as nested (foreignObject + HTML can't render raw SVG paths) - elements.push( - - ); - } + if (!desc) continue; + const cells = descriptionCells(desc, "#c026d3"); + const order = ["C", "D", "E", "F", "G", "H"] as const; + order.forEach((col, ci) => { + const cell = cells[col]; + if (!cell) return; + const sx = sheetX + (ci + 2) * cellSize; + if (cell.kind === "text") { + elements.push( + + {cell.text} + , + ); + } else { + elements.push( + , + ); } - } + }); } } diff --git a/packages/web/src/components/TileLayer.tsx b/packages/web/src/components/TileLayer.tsx index 469ed5a..0256ad7 100644 --- a/packages/web/src/components/TileLayer.tsx +++ b/packages/web/src/components/TileLayer.tsx @@ -38,6 +38,11 @@ interface Props { half?: "top" | "bottom"; /** Stacking order relative to sibling overlays. */ zIndex?: number; + /** + * Render from the shared blob cache without fetching or cancelling. + * Used by the placement loupe so it never fights the main map's queue. + */ + passive?: boolean; /** Optional test id on the outer container. */ "data-testid"?: string; } @@ -295,6 +300,7 @@ export function TileLayer({ tileVersion, half = "top", zIndex, + passive = false, "data-testid": testId, }: Props) { const cache = useTileBlobCache(); @@ -368,8 +374,9 @@ export function TileLayer({ // Fetch desired tiles; abort ones that left the viewport. // Only the top half drives fetching so the ink layer does not double - // the queue when both share a cache. + // the queue when both share a cache. Passive layers skip entirely. useEffect(() => { + if (passive) return; if (half !== "top" && cache) { // Ink layer: still need to fetch if used alone, but when shared the // top half owns the queue. Mirror desired keys so blobs stay warm. @@ -430,7 +437,7 @@ export function TileLayer({ return () => { if (timer) clearTimeout(timer); }; - }, [tiles, backdropTiles, cache, half]); + }, [tiles, backdropTiles, cache, half, passive]); const allCurrentLoaded = tiles.length > 0 && tiles.every((t) => cache.blobUrls.has(t.key)); diff --git a/packages/web/src/i18n/locales/en/controls.json b/packages/web/src/i18n/locales/en/controls.json index b2dc7be..5910bb3 100644 --- a/packages/web/src/i18n/locales/en/controls.json +++ b/packages/web/src/i18n/locales/en/controls.json @@ -204,9 +204,10 @@ "title": "Control description — {{code}}", "columnC": "Which of similar features", "columnD": "Control feature", - "columnE": "Dimensions", - "columnF": "Combination", + "columnE": "Appearance / second feature", + "columnF": "Dimensions / combination", "columnG": "Location of the control flag", + "columnH": "Other information", "dGroup1": "Landforms", "dGroup2": "Rock and boulders", "dGroup3": "Water and marsh", @@ -214,6 +215,12 @@ "dGroup5": "Man-made features", "dGroup6": "Special items", "dimensionsPlaceholder": "e.g. 1,5 or 2x3", + "appearanceHint": "Appearance of the feature (low, deep, rocky…).", + "combinationHint": "Crossing, junction or bend — replaces dimensions in column F.", + "showSecondFeature": "Pick a second feature (for crossing / junction)", + "hideSecondFeature": "Hide second-feature picker", + "secondFeatureHint": "Shown in column E together with a combination symbol in F.", + "emptySummary": "No symbols selected", "clearAll": "Clear all", "cancel": "Cancel", "save": "Save" diff --git a/packages/web/src/i18n/locales/en/courses.json b/packages/web/src/i18n/locales/en/courses.json index 99180e3..be4a314 100644 --- a/packages/web/src/i18n/locales/en/courses.json +++ b/packages/web/src/i18n/locales/en/courses.json @@ -149,7 +149,12 @@ "moveDown": "Move down", "removeFromCourse": "Remove from course", "srrBadge": "SRR", - "radioBadge": "Radio" + "radioBadge": "Radio", + "addSpecial": "Add instruction", + "finishVariant": "Finish", + "specialLength": "Length (m)", + "removeSpecial": "Remove instruction", + "suggestedSingle": "Suggested: {{label}}" }, "openInCourseEditor": "Open in course editor", "seriesExhausted": "Club series exhausted — assigning the next free code ≥ 31." diff --git a/packages/web/src/i18n/locales/sv/controls.json b/packages/web/src/i18n/locales/sv/controls.json index 1b83826..bc468a4 100644 --- a/packages/web/src/i18n/locales/sv/controls.json +++ b/packages/web/src/i18n/locales/sv/controls.json @@ -204,9 +204,10 @@ "title": "Kontrollbeskrivning — {{code}}", "columnC": "Vilken av flera lika", "columnD": "Kontrollföremål", - "columnE": "Dimensioner", - "columnF": "Kombination", + "columnE": "Utseende / andra föremålet", + "columnF": "Dimensioner / kombination", "columnG": "Skärmens placering", + "columnH": "Övrig information", "dGroup1": "Terrängformer", "dGroup2": "Sten och berg", "dGroup3": "Vatten och kärr", @@ -214,6 +215,12 @@ "dGroup5": "Anläggningar", "dGroup6": "Specialföremål", "dimensionsPlaceholder": "t.ex. 1,5 eller 2x3", + "appearanceHint": "Föremålets utseende (låg, djup, stenig…).", + "combinationHint": "Korsning, förgrening eller krök — ersätter dimensioner i kolumn F.", + "showSecondFeature": "Välj ett andra föremål (för korsning / förgrening)", + "hideSecondFeature": "Dölj andra-föremål-väljaren", + "secondFeatureHint": "Visas i kolumn E tillsammans med en kombinationssymbol i F.", + "emptySummary": "Inga symboler valda", "clearAll": "Rensa allt", "cancel": "Avbryt", "save": "Spara" diff --git a/packages/web/src/i18n/locales/sv/courses.json b/packages/web/src/i18n/locales/sv/courses.json index 29f9558..a79f396 100644 --- a/packages/web/src/i18n/locales/sv/courses.json +++ b/packages/web/src/i18n/locales/sv/courses.json @@ -149,7 +149,12 @@ "moveDown": "Flytta ner", "removeFromCourse": "Ta bort från banan", "srrBadge": "SRR", - "radioBadge": "Radio" + "radioBadge": "Radio", + "addSpecial": "Lägg till anvisning", + "finishVariant": "Mål", + "specialLength": "Längd (m)", + "removeSpecial": "Ta bort anvisning", + "suggestedSingle": "Förslag: {{label}}" }, "openInCourseEditor": "Öppna i banläggningen", "seriesExhausted": "Klubbens serier är slut — tilldelar nästa lediga kod ≥ 31." diff --git a/packages/web/src/iof-symbols.ts b/packages/web/src/iof-symbols.ts index 41785af..9acba91 100644 --- a/packages/web/src/iof-symbols.ts +++ b/packages/web/src/iof-symbols.ts @@ -1,8 +1,14 @@ export { IOF_SYMBOLS, getDescriptionSymbols, + descriptionCells, ocadCtoIof, ocadDtoIof, + ocadEtoIof, ocadFtoIof, ocadGtoIof, + ocadHtoIof, + ocadDescriptionCodeToIof, + type DescriptionCell, + type DescriptionCells, } from "@oxygen/shared"; diff --git a/packages/web/src/lib/__tests__/control-description-options.test.ts b/packages/web/src/lib/__tests__/control-description-options.test.ts index 03e368d..5f5b00e 100644 --- a/packages/web/src/lib/__tests__/control-description-options.test.ts +++ b/packages/web/src/lib/__tests__/control-description-options.test.ts @@ -2,25 +2,36 @@ import { describe, expect, it } from "vitest"; import { C_OPTIONS, D_GROUPS, + E_OPTIONS, F_OPTIONS, G_DIRECTIONAL, G_OPTIONS, G_PLAIN, + H_OPTIONS, OPTIONS_BY_FIELD, ocadToIof, } from "../control-description-options"; -import { IOF_SYMBOLS, getDescriptionSymbols } from "../../iof-symbols"; +import { IOF_SYMBOLS, descriptionCells } from "../../iof-symbols"; import { IOF_SYMBOL_META, iofSymbolName } from "../../iof-symbol-meta"; -/** Column rendered by getDescriptionSymbols for each description field. */ -const RENDER_COLUMN = { c: "colC", d: "colD", f: "colF", g: "colG" } as const; +/** Sheet column that a description field renders into. */ +const RENDER_COLUMN = { + c: "C", + d: "D", + e: "E", + f: "F", + g: "G", + h: "H", +} as const; describe("control description options", () => { it("covers the full IOF symbol sets per column", () => { expect(C_OPTIONS).toHaveLength(11); expect(D_GROUPS.flatMap((g) => g.options)).toHaveLength(73); + expect(E_OPTIONS.length).toBeGreaterThanOrEqual(11); expect(F_OPTIONS).toHaveLength(3); expect(G_OPTIONS).toHaveLength(8 * 8 + 6); + expect(H_OPTIONS.length).toBeGreaterThanOrEqual(3); }); it("groups column D by IOF family 1..6", () => { @@ -49,32 +60,50 @@ describe("control description options", () => { } }); - // The core guarantee: what the editor stores renders as the symbol - // the user picked, via the untouched converters in iof-symbols.ts. - it("round-trips every option's OCAD code to its IOF symbol via the renderer", () => { + it("round-trips every option's OCAD code to its IOF symbol via descriptionCells", () => { for (const [field, options] of Object.entries(OPTIONS_BY_FIELD)) { const column = RENDER_COLUMN[field as keyof typeof RENDER_COLUMN]; for (const opt of options) { expect(IOF_SYMBOLS[opt.iof], `${field} ${opt.iof} has an SVG`).toBeTruthy(); - const rendered = getDescriptionSymbols({ [field]: opt.ocad }, "black"); - expect(rendered[column], `${field} ${opt.ocad} → ${opt.iof}`).toBe( - IOF_SYMBOLS[opt.iof], - ); + const cells = descriptionCells({ [field]: opt.ocad }, "black"); + const cell = cells[column]; + expect(cell, `${field} ${opt.ocad} → ${opt.iof}`).toEqual({ + kind: "svg", + key: opt.iof, + svg: IOF_SYMBOLS[opt.iof], + }); } } }); + it("puts dimensions in column F (not E) and appearance in E", () => { + const sized = descriptionCells({ d: "2.004", s: "1,5" }, "black"); + expect(sized.E).toBeNull(); + expect(sized.F).toEqual({ kind: "text", text: "1.5" }); + + const appearance = descriptionCells({ d: "2.004", e: "8.001" }, "black"); + expect(appearance.E?.kind).toBe("svg"); + expect(appearance.E && appearance.E.kind === "svg" && appearance.E.key).toBe("8.1"); + + // Combination wins over dimensions in F. + const crossing = descriptionCells( + { d: "5.002", e: "5.001", f: "10.001", s: "2" }, + "black", + ); + expect(crossing.F?.kind).toBe("svg"); + expect(crossing.F && crossing.F.kind === "svg" && crossing.F.key).toBe("10.1"); + }); + it("resolves stored OCAD codes back to IOF keys, including imported encodings", () => { - // Canonical editor codes. expect(ocadToIof("c", "0.201")).toBe("0.1N"); expect(ocadToIof("d", "2.004")).toBe("2.4"); + expect(ocadToIof("e", "8.001")).toBe("8.1"); expect(ocadToIof("f", "10.001")).toBe("10.1"); expect(ocadToIof("g", "11.101")).toBe("11.1N"); - // OCD-imported variants that differ from the canonical encoding. + expect(ocadToIof("h", "12.001")).toBe("12.1"); expect(ocadToIof("c", "0.3")).toBe("0.3"); expect(ocadToIof("d", "2.4")).toBe("2.4"); expect(ocadToIof("g", "11.143")).toBe("11.14E"); - // Garbage stays null. expect(ocadToIof("d", "99.999")).toBeNull(); expect(ocadToIof("g", "")).toBeNull(); }); @@ -90,7 +119,9 @@ describe("control description options", () => { } expect(iofSymbolName("2.4", "en")).toBe("Boulder"); expect(iofSymbolName("2.4", "sv")).toBe("Sten"); - expect(iofSymbolName("2.4", "de")).toBe("Boulder"); // falls back to English + expect(iofSymbolName("8.1", "en")).toBe("Low"); + expect(iofSymbolName("12.1", "sv")).toBe("Sjukvård"); + expect(iofSymbolName("2.4", "de")).toBe("Boulder"); expect(iofSymbolName("nope", "en")).toBe("nope"); }); }); diff --git a/packages/web/src/lib/control-description-options.ts b/packages/web/src/lib/control-description-options.ts index 96a9132..79602bc 100644 --- a/packages/web/src/lib/control-description-options.ts +++ b/packages/web/src/lib/control-description-options.ts @@ -10,7 +10,14 @@ * symbol (round-trip verified by unit tests). */ -import { ocadCtoIof, ocadDtoIof, ocadFtoIof, ocadGtoIof } from "../iof-symbols"; +import { + ocadCtoIof, + ocadDtoIof, + ocadEtoIof, + ocadFtoIof, + ocadGtoIof, + ocadHtoIof, +} from "../iof-symbols"; import { IOF_SYMBOL_META } from "../iof-symbol-meta"; export interface DescriptionOption { @@ -25,11 +32,31 @@ export const COMPASS_DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] a const dirIndex = (dir: string): number => COMPASS_DIRECTIONS.indexOf(dir as never) + 1; +function optionsOfKind(kind: string): DescriptionOption[] { + const keys = Object.keys(IOF_SYMBOL_META) + .filter((k) => IOF_SYMBOL_META[k].kind === kind) + .sort((a, b) => a.localeCompare(b, "en", { numeric: true })); + return keys.map((iof) => { + const [grp, sub] = iof.split("."); + // Directional keys like "0.1N" / "11.1NE" keep the direction suffix + // out of the OCAD sub — callers that need directions build them + // themselves (C_OPTIONS, G_DIRECTIONAL). Plain numeric keys pad the + // sub to 3 digits. + const numeric = /^\d+$/.test(sub ?? ""); + return { + iof, + ocad: numeric + ? `${grp}.${sub.padStart(3, "0")}` + : `${grp}.${sub}`, // unused for directional; overwritten below + }; + }); +} + // ─── Column C: which of similar features ───────────────────────────── const C_CARDINAL = new Set(["N", "E", "S", "W"]); -/** 8 compass directions ("N side of…") followed by upper/lower/middle. */ +/** 8 compass directions ("Northern…") followed by upper/lower/middle. */ export const C_OPTIONS: DescriptionOption[] = [ ...COMPASS_DIRECTIONS.map((dir) => ({ iof: `${C_CARDINAL.has(dir) ? "0.1" : "0.2"}${dir}`, @@ -64,6 +91,16 @@ export const D_GROUPS: { group: number; options: DescriptionOption[] }[] = (() = .map(([group, options]) => ({ group, options })); })(); +// ─── Column E: appearance (8.x) ────────────────────────────────────── + +/** Appearance symbols (Low, Shallow, Deep, …). Second-feature picks reuse D_GROUPS. */ +export const E_OPTIONS: DescriptionOption[] = optionsOfKind("E").filter((o) => + /^\d+\.\d+$/.test(o.iof), +).map((o) => { + const [grp, sub] = o.iof.split("."); + return { iof: o.iof, ocad: `${grp}.${sub.padStart(3, "0")}` }; +}); + // ─── Column F: combination ─────────────────────────────────────────── /** Crossing, junction, bend — matches OCAD_F_MAP in iof-symbols.ts. */ @@ -114,12 +151,42 @@ export const G_OPTIONS: DescriptionOption[] = [ ...G_PLAIN, ]; +// ─── Column H: other information ───────────────────────────────────── + +export const H_OPTIONS: DescriptionOption[] = optionsOfKind("H").filter((o) => + /^\d+\.\d+$/.test(o.iof), +).map((o) => { + const [grp, sub] = o.iof.split("."); + return { iof: o.iof, ocad: `${grp}.${sub.padStart(3, "0")}` }; +}); + +// ─── Special-instruction / finish row kinds ────────────────────────── + +/** Between-control specials (13.x) + map-issue (13.6). */ +export const SPECIAL_OPTIONS: DescriptionOption[] = [ + { iof: "13.1", ocad: "13.001" }, + { iof: "13.2", ocad: "13.002" }, + { iof: "13.3", ocad: "13.003" }, + { iof: "13.4", ocad: "13.004" }, + { iof: "13.5", ocad: "13.005" }, + { iof: "13.6", ocad: "13.006" }, +]; + +/** Finish-row variants (14.x). */ +export const FINISH_OPTIONS: DescriptionOption[] = [ + { iof: "14.1", ocad: "14.001" }, + { iof: "14.2", ocad: "14.002" }, + { iof: "14.3", ocad: "14.003" }, +]; + /** All options for one ControlDescription field. */ export const OPTIONS_BY_FIELD = { c: C_OPTIONS, d: D_GROUPS.flatMap((g) => g.options), + e: E_OPTIONS, f: F_OPTIONS, g: G_OPTIONS, + h: H_OPTIONS, } as const; /** @@ -129,11 +196,16 @@ export const OPTIONS_BY_FIELD = { * encoding than the editor's canonical one (several OCAD sub-codes can * mean the same symbol). */ -export function ocadToIof(field: "c" | "d" | "f" | "g", ocad: string): string | null { +export function ocadToIof( + field: "c" | "d" | "e" | "f" | "g" | "h", + ocad: string, +): string | null { switch (field) { case "c": return ocadCtoIof(ocad); case "d": return ocadDtoIof(ocad); + case "e": return ocadEtoIof(ocad); case "f": return ocadFtoIof(ocad); case "g": return ocadGtoIof(ocad); + case "h": return ocadHtoIof(ocad); } } diff --git a/packages/web/src/lib/course-map-types.ts b/packages/web/src/lib/course-map-types.ts index 4ce20d6..ff1fb96 100644 --- a/packages/web/src/lib/course-map-types.ts +++ b/packages/web/src/lib/course-map-types.ts @@ -8,6 +8,7 @@ import { type CourseOverlayControl, type CourseOverlayLeg, type DescriptionRow, + type DescriptionSheetHeader, type MapTemplateSettings, type MapTextValues, type MapWindow, @@ -49,6 +50,8 @@ export interface CourseMapView { controls: CourseOverlayControl[]; legs: CourseOverlayLeg[]; descriptionRows: DescriptionRow[]; + /** IOF 3-row header for course maps; null/undefined → single title row. */ + descriptionHeader?: DescriptionSheetHeader | null; validation: { valid: boolean; issues: Array<{ @@ -67,6 +70,7 @@ export interface TemplateLayoutPreview { controls: CourseOverlayControl[]; legs: CourseOverlayLeg[]; descriptionRows: DescriptionRow[]; + descriptionHeader?: DescriptionSheetHeader | null; textValues: MapTextValues; } diff --git a/packages/web/src/lib/structured-search/__tests__/new-anchors.test.ts b/packages/web/src/lib/structured-search/__tests__/new-anchors.test.ts index 112caf1..e572117 100644 --- a/packages/web/src/lib/structured-search/__tests__/new-anchors.test.ts +++ b/packages/web/src/lib/structured-search/__tests__/new-anchors.test.ts @@ -76,9 +76,9 @@ describe("class anchors", () => { describe("course anchors", () => { const anchors = createCourseAnchors(lbl) as AnchorDef[]; const courses: CourseSummary[] = [ - { id: 1, name: "Lång", controls: "31;42;55", controlCount: 3, length: 5200, climb: 100, numberOfMaps: 30, firstAsStart: false, lastAsFinish: true, startControlId: null, finishControlId: null }, - { id: 2, name: "Medel", controls: "31;42", controlCount: 2, length: 3200, climb: 50, numberOfMaps: 20, firstAsStart: true, lastAsFinish: false, startControlId: null, finishControlId: null }, - { id: 3, name: "Kort", controls: "31", controlCount: 1, length: 1500, climb: 20, numberOfMaps: 5, firstAsStart: true, lastAsFinish: true, startControlId: null, finishControlId: null }, + { id: 1, name: "Lång", controls: "31;42;55", controlCount: 3, length: 5200, climb: 100, numberOfMaps: 30, firstAsStart: false, lastAsFinish: true, startControlId: null, finishControlId: null, descriptionInstructions: null }, + { id: 2, name: "Medel", controls: "31;42", controlCount: 2, length: 3200, climb: 50, numberOfMaps: 20, firstAsStart: true, lastAsFinish: false, startControlId: null, finishControlId: null, descriptionInstructions: null }, + { id: 3, name: "Kort", controls: "31", controlCount: 1, length: 1500, climb: 20, numberOfMaps: 5, firstAsStart: true, lastAsFinish: true, startControlId: null, finishControlId: null, descriptionInstructions: null }, ]; it("filters by length in km", () => { diff --git a/packages/web/src/pages/CourseEditorPage.tsx b/packages/web/src/pages/CourseEditorPage.tsx index 404ce4e..51af4b6 100644 --- a/packages/web/src/pages/CourseEditorPage.tsx +++ b/packages/web/src/pages/CourseEditorPage.tsx @@ -324,7 +324,13 @@ export function CourseEditorPage() { setShowExhausted(true); } const created = await run(() => - client.control.create.mutate({ codes: String(code), status: 0, xpos: pt.x, ypos: pt.y }), + client.control.create.mutate({ + codes: String(code), + status: 0, + xpos: pt.x, + ypos: pt.y, + autoDescribe: true, + }), ); if (!created) return; if (entry?.type === "srr") { @@ -432,12 +438,27 @@ export function CourseEditorPage() { // suggestion query keys off the new position once it refetches). setLastMovedId(id); void (async () => { - const redo = () => client.control.update.mutate({ id, xpos: pt.x, ypos: pt.y }); + const redo = () => + client.control.update.mutate({ + id, + xpos: pt.x, + ypos: pt.y, + // The server replaces an *untouched* auto description with the + // new spot's suggestion; a hand-edited one stays and the menu + // offers the new top suggestion if it differs. + autoDescribe: true, + }); const done = await run(redo); if (done === undefined || !prev) return; undoStack.push({ redo, - undo: () => client.control.update.mutate({ id, xpos: prev.mapX, ypos: prev.mapY }), + undo: () => + client.control.update.mutate({ + id, + xpos: prev.mapX, + ypos: prev.mapY, + description: prev.description ?? null, + }), }); bumpHistory(); })(); @@ -541,10 +562,10 @@ export function CourseEditorPage() { const closeDescription = useCallback(() => setDescControlId(null), []); // ─── Description autodetect ────────────────────────────── - // What does the base map say the selected control sits on? Asked for a - // placed control with no description yet (the freshly placed one, which - // the reducer auto-selects) — and for the just-moved control, even if - // it already has one (see `lastMovedId`). + // After a move of a hand-described control, offer the single top + // suggestion if it differs from the current description. Fresh places + // get autoDescribe on create and moves of untouched auto descriptions + // are re-described server-side, so the menu rarely has anything to say. useEffect(() => { if (lastMovedId != null && state.selectedControlId !== lastMovedId) { @@ -554,7 +575,8 @@ export function CourseEditorPage() { const suggestFor = selectedControl && - (!selectedControl.description || selectedControl.id === lastMovedId) && + selectedControl.id === lastMovedId && + !!selectedControl.description && (selectedControl.mapX !== 0 || selectedControl.mapY !== 0) && !state.phantom && descControlId === null @@ -570,7 +592,6 @@ export function CourseEditorPage() { const applyDescription = useCallback( (id: number, next: ControlDescription) => { const prev = coordsById.get(id)?.description ?? null; - // Applying settles the description question a move re-opened. setLastMovedId((v) => (v === id ? null : v)); void (async () => { const redo = () => client.control.update.mutate({ id, description: next }); @@ -587,30 +608,64 @@ export function CourseEditorPage() { ); /** - * Suggestion rows for the context menu. The viewer takes labels and - * SVG fragments ready-made, so symbol lookup and localization stay - * here (same contract as `moveWarnings`). + * Single top suggestion for the context menu (only after a move, and + * only when it differs from the control's current description). */ const suggestions = useMemo(() => { const id = suggestFor?.id; + const current = suggestFor?.description; if (id == null) return []; - const rows: EditorDescriptionSuggestion[] = []; - for (const c of suggestQuery.data?.candidates ?? []) { - const iofD = ocadToIof("d", c.d); - if (!iofD) continue; - const iofG = c.g ? ocadToIof("g", c.g) : null; - const name = iofSymbolName(iofD, i18n.language); - rows.push({ - id: String(c.isom), - label: iofG ? `${name} · ${iofSymbolName(iofG, i18n.language)}` : name, + const top = suggestQuery.data?.candidates?.[0]; + if (!top) return []; + const next: ControlDescription = { + d: top.d, + ...(top.c ? { c: top.c } : {}), + ...(top.e ? { e: top.e } : {}), + ...(top.f ? { f: top.f } : {}), + ...(top.g ? { g: top.g } : {}), + }; + // Same D (+ optional G/C/F/E) → nothing useful to offer. + if ( + current && + current.d === next.d && + current.g === next.g && + current.c === next.c && + current.f === next.f && + current.e === next.e + ) { + return []; + } + const iofD = ocadToIof("d", top.d); + if (!iofD) return []; + const parts: string[] = []; + if (top.c) { + const iofC = ocadToIof("c", top.c); + if (iofC) parts.push(iofSymbolName(iofC, i18n.language)); + } + parts.push(iofSymbolName(iofD, i18n.language)); + if (top.e) { + const iofE = ocadToIof("e", top.e); + if (iofE) parts.push(iofSymbolName(iofE, i18n.language)); + } + if (top.f) { + const iofF = ocadToIof("f", top.f); + if (iofF) parts.push(iofSymbolName(iofF, i18n.language)); + } + if (top.g) { + const iofG = ocadToIof("g", top.g); + if (iofG) parts.push(iofSymbolName(iofG, i18n.language)); + } + const iofG = top.g ? ocadToIof("g", top.g) : null; + return [ + { + id: String(top.isom), + label: t("editor.suggestedSingle", { label: parts.join(" · ") }), symbolSvg: IOF_SYMBOLS[iofD] ?? null, sideSvg: iofG ? IOF_SYMBOLS[iofG] ?? null : null, - onApply: () => - applyDescription(id, { d: c.d, ...(c.g ? { g: c.g } : {}) }), - }); - } - return rows; - }, [suggestFor?.id, suggestQuery.data, i18n.language, applyDescription]); + onApply: () => applyDescription(id, next), + }, + ]; + }, [suggestFor, suggestQuery.data, i18n.language, applyDescription, t]); /** Append an existing control to the selected course, undoably. */ const appendControlToCourse = useCallback( @@ -713,6 +768,9 @@ export function CourseEditorPage() { const contextActions = useMemo(() => { const actions: EditorContextAction[] = []; + // createControl needs the code universe to pick the next free code + // and bails out silently without it — show that as disabled instead. + const codesReady = !!controlList.data; if (state.phantom) { const pt = { x: state.phantom.x, y: state.phantom.y }; if (state.phantom.insertAt !== null && selectedCourse) { @@ -721,6 +779,7 @@ export function CourseEditorPage() { id: "insert", label: t("editor.actionInsert"), onClick: () => void createControl(pt, at), + disabled: !codesReady, }); } else { if (selectedCourse) { @@ -728,12 +787,14 @@ export function CourseEditorPage() { id: "add-to-course", label: t("editor.actionAddToCourse", { name: selectedCourse.name }), onClick: () => void createControl(pt, sequenceIds.length), + disabled: !codesReady, }); } actions.push({ id: "add", label: t("editor.actionAdd"), onClick: () => void createControl(pt, null), + disabled: !codesReady, }); actions.push({ id: "add-start", @@ -821,14 +882,17 @@ export function CourseEditorPage() { label: t("editor.deleteControl"), variant: "danger", onClick: handleDelete, + // handleDelete refuses while a save is in flight; show that + // instead of swallowing the click. + disabled: pendingOps > 0, }); } return actions; }, [state.phantom, state.selectedControlId, selectedCourse, sequenceIds, - coordsById, controlCoords.data, createControl, createRoleControl, + coordsById, controlCoords.data, controlList.data, createControl, createRoleControl, assignRoleControl, appendControlToCourse, removeControlFromCourse, radioSwapOffer, confirmRadioSwap, declineRadioSwap, controlRowsById, - handleRadioToggle, handleDelete, openDescription, t]); + handleRadioToggle, handleDelete, openDescription, pendingOps, t]); /** Ids (as overlay strings) of the edited course's controls — these * stay at full strength while everything else fades. */ @@ -1396,6 +1460,52 @@ export function CourseEditorPage() { {t("controlCount", { count: sequenceIds.length })} {totalMeters} m
+
+ +
{showClone ? ( <> @@ -1443,7 +1553,7 @@ export function CourseEditorPage() { newCourseName, newCourseSuggestions, handleCreateCourse, displaySeq, legMeters, totalMeters, sequenceIds.length, moveInSequence, removeFromSequence, openClone, showClone, cloneName, handleCloneCourse, - controlRowsById, seriesAllocation.data], + controlRowsById, seriesAllocation.data, client, run, undoStack, bumpHistory], ); // ─── In-map inventory panel ────────────────────────────── diff --git a/packages/web/src/pages/MapTemplatesPage.tsx b/packages/web/src/pages/MapTemplatesPage.tsx index 971f721..449cb96 100644 --- a/packages/web/src/pages/MapTemplatesPage.tsx +++ b/packages/web/src/pages/MapTemplatesPage.tsx @@ -512,6 +512,7 @@ export function MapTemplatesPage() { controls={data.controls} legs={data.legs} descriptionRows={data.descriptionRows} + descriptionHeader={data.descriptionHeader ?? null} descriptionTitle={data.textValues.course || editingTemplate.name} mapObjects={[]} templateObjects={editingTemplate.objects} diff --git a/packages/web/src/pages/MapsPage.tsx b/packages/web/src/pages/MapsPage.tsx index 726dea2..4fbd16a 100644 --- a/packages/web/src/pages/MapsPage.tsx +++ b/packages/web/src/pages/MapsPage.tsx @@ -697,6 +697,7 @@ export function MapsPage() { controls={editingMap.controls} legs={editingMap.legs} descriptionRows={editingMap.descriptionRows} + descriptionHeader={editingMap.descriptionHeader ?? null} descriptionTitle={ editingMap.kind === "all_controls" ? editingMap.name diff --git a/scripts/generate-iof-symbol-meta.mjs b/scripts/generate-iof-symbol-meta.mjs index 22a188f..7658a64 100644 --- a/scripts/generate-iof-symbol-meta.mjs +++ b/scripts/generate-iof-symbol-meta.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node // Generates packages/shared/src/course-maps/iof-symbol-meta.ts from the // svg-control-descriptions package's lang.json (names extracted from -// Purple Pen's symbols.xml). Only kinds used by the control description -// editor are emitted (C, D, F, G), filtered to symbols that actually -// exist in packages/shared/src/course-maps/iof-symbols.ts, and only the languages -// Oxygen ships (en, sv). +// Purple Pen's symbols.xml). Emits every kind used by the control +// description sheet / editor (A, C–H, special-instruction letters), +// filtered to symbols that actually exist in iof-symbols.ts, and only +// the languages Oxygen ships (en, sv). // // Usage: node scripts/generate-iof-symbol-meta.mjs @@ -25,15 +25,19 @@ const availableKeys = new Set( [...symbolsSource.matchAll(/^ {2}"([^"]+)":/gm)].map((m) => m[1]), ); -const KINDS = new Set(["C", "D", "F", "G"]); +/** Sheet / editor kinds we surface (includes special-instruction rows). */ +const KINDS = new Set(["A", "C", "D", "E", "F", "G", "H", "W", "X", "Y", "Z", "V"]); // Purple Pen's lang.json lacks Swedish names for a few IOF 2018 symbols. const SV_FALLBACKS = { "2.10": "Skyttegrav", "5.25": "Rabatt / förbjudet område", "5.26": "Järnväg", + "15.6": "Kartvändning", }; +const KIND_UNION = [...KINDS].map((k) => JSON.stringify(k)).join(" | "); + const entries = []; for (const [key, info] of Object.entries(lang)) { if (!KINDS.has(info.kind)) continue; @@ -56,8 +60,8 @@ const out = `// Auto-generated by scripts/generate-iof-symbol-meta.mjs. Do not e // svg-control-descriptions package (extracted from Purple Pen). export interface IofSymbolMeta { - /** Description sheet column: C, D, F or G. */ - kind: "C" | "D" | "F" | "G"; + /** Description sheet column / row kind. */ + kind: ${KIND_UNION}; en: string; sv: string; }