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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docs/bugfix-control-description-columns.md
Original file line number Diff line number Diff line change
@@ -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`.
36 changes: 29 additions & 7 deletions docs/control-descriptions-and-editor-geometry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
156 changes: 80 additions & 76 deletions docs/course-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 12 additions & 3 deletions docs/course-maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
Loading
Loading