Skip to content

Frontend redesign - #18

Closed
valentinps wants to merge 15 commits into
mainfrom
frontend-redesign
Closed

Frontend redesign#18
valentinps wants to merge 15 commits into
mainfrom
frontend-redesign

Conversation

@valentinps

Copy link
Copy Markdown
Owner

No description provided.

valentinps and others added 15 commits July 28, 2026 22:12
Nothing in a save (or docs.json) records a cave, so the source is the
cooked world-partition export: the game's own cave atmosphere volumes
(the fog/lighting regions it swaps you into underground -- 108 of them,
some with authored names), the BP_CaveFloor tunnel splines, the cave-only
foliage clusters and the placed cave rock kit. extract_caves.py unions all
four, closes the gaps between one cave's separately-authored chambers, and
traces the outline of each connected component into the committed
game_data/sav_data/caves.json (84 caves, 61 KB).

The core embeds that table and emits it as a new static payload step, and
the frontend draws every ring in one line bucket under a "Caves" sidebar
row (default hidden like the other world layers), with the cave's name,
area and altitude range in the tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two invisible edges, both authored level geometry with no representation in
a save:

- The damaging border is 15 FGDamageOverTimeVolume actors whose DoT class
  comes from /World/Hazard/WorldPerimeter/. Eleven are vertical walls (four
  axis-aligned, three rotated blocks cutting the NE/NW/SW corners, four more
  as an outer backstop); the safe side is a 7-vertex polygon, 7915 x 7049 m.
  The other four span the whole world in XY and are altitude limits instead:
  damage above +2000 m and below -244 m.

- The water limit is the union of the 270 FGWaterVolume actors (all carry
  mResourceClass Desc_Water_C, i.e. swimmable and extractor-valid). The
  rendered ocean is a different thing entirely: 31 water-plane patches, the
  biggest 51 x 34 km, so the sea you can see runs ~70 km while the water you
  can touch stops within ~8 km -- on the west side the real water ends 334 m
  before the damage wall, with visual-only ocean in between.

The perimeter polygon is written as an exact half-plane intersection of the
wall boxes and cross-checked both ways against an independent rasterize-and-
trace of the same volumes (they agree to 40 cm shy of a cell).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a "Map Limits" sidebar category (default hidden, like the other world
layers) with one row per ring: the world border in red, the water limit in
cyan, each a line bucket with the facts in its tooltip -- the altitudes the
damage slabs start at for the border, and "8.2 km of real water inside a
70 km ocean" for the water.

The rings ride along as a new static payload step fed by the embedded
worldBounds.json, the same shape as the spawners step.

Two things worth knowing for the next static layer:

- ring z is 0 (sea level), not the volumes' real -9.6..+10.4 km span: a
  line's z only drives the altitude filter and depth sorting, so the honest
  span would just make the tooltip claim the border sits 10 km up.
- synthetic ids need a ':' in them. Bulk id arrays get the instance-name
  prefix re-added client-side (slim_payload_value / expandPayloadIds), which
  silently turned "worldPerimeter" into "Persistent_Level:PersistentLevel.
  worldPerimeter" and broke the color lookup keyed off it. The frontend now
  keys off a separate `kinds` array, and a test pins the ':' rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The caves outlines, the damaging world border and the water limit are three
answers to the same question -- what is the terrain actually like here? --
and none of them belongs to the save being viewed, so they now share one
sidebar category instead of two. Three rows, three colors, one checkbox
each; "Map Limits" is gone as a name.

Payload steps stay separate (caves + mapLimits), so the collectors and their
tests are untouched apart from the step count going to 20.
The old split was game_data/sav_data/ (committed) vs game_data/generated/
(gitignored), which read as "world tables vs docs.json extracts" but was
really "the IP line we drew", and membership had drifted: creatureSpawners
(coordinates, like the committed slugs) was gitignored, half the hand-written
tables sat in sav_data/ and half at game_data/ root, and two generated files
had no consumer at all.

Now:

  game_data/curated/          committed    hand-maintained inputs only
  game_data/generated/docs/   gitignored   Docs.json extracts
  game_data/generated/world/  gitignored   level-export tables
  game_data/generated/        gitignored   map render + tiles

so a checkout is either extracted (extract_all.py) or unpacked
(package_game_data.py unpack), never half-committed. game_data/README.md
states the rule where the folders are; sav_data/ is gone, and so are
sav_data_gaps.json (no consumers) and the flat generated/*.json layout.

Two things had to be fixed for "nothing generated is committed" to actually
hold, both of which were latent bugs in the old layout:

- freeDroppedItems.json recovered each pickup's item class from its OWN last
  output (FModel can't decode the cooked FInventoryItem struct). Delete the
  file and all 703 pickups lose their item. That knowledge now lives in the
  committed curated/pickupItems.json, an input; --items-from-save writes what
  it learns back into it.
- every table's key order was inherited from the previously committed file,
  so a fresh machine produced different bytes than an incremental re-run.
  Tables are now written sorted: same dump in, byte-identical files out.
  Verified by deleting generated/{docs,world}/ and re-running extract_all --
  every table came back byte-identical to the pre-reorg ones.

Also: package_game_data packs generated/ recursively so the new subfolders
need no change there, but an archive built before this commit carries no
world/ tables (they used to be committed), so CI now points at game-data-v3
-- publish the rebuilt zip under that tag or CI cannot compile the crate.
sav_core's build script now says which command to run when the data is
missing instead of failing on the first include_str!.

NOTICES/README: the distribution clause said game-derived data is "not
distributed here" while CI downloaded exactly that from a release. Reworded
to say what is true -- none of it is in the repository, and the archive is a
convenience build of your own extraction.
The row now reads "Caves (approximate)", hovering it explains why, and a
cave's tooltip carries an "Outline: Approximate" line where someone reading
a size off it will see it.

The number is honest about its source: an outline is the game's cave fog
volume unioned with the cave geometry inside it, which runs generous by tens
of meters and can miss a bare tunnel with no cave props in it. That finds a
cave; it does not survey one, and the layer should not imply otherwise.

row.hint is new on leaf rows -- the long version of a caveat a label can only
gesture at, shown as a title on hover.
…atching

Three separate frictions in the search bar, all of them cases where the
answer was already on screen but out of reach.

A collectable item's suggestion row now carries the same show/hide eye a
building or layer row has, flipping that pickup's own Collectables rows.
Searching "Mercer Sphere" honestly means two things -- what is stashed in
inventories, and what is still lying out there -- and only the first had an
answer. The eye drives the real sidebar checkboxes rather than a second
state of its own, and appears on exactly the six items that have a layer at
all: the three slugs, Somersloop, Mercer Sphere, Hard Drive.

Every individual location in an item's result list gets a crosshair button
that flies the map to that one machine and marks it with a solid pink pin
(every other pin on the map is white-filled, so this one cannot be mistaken
for scenery). Coordinates told you where a building was; this takes you
there. It is deliberately independent of "Show only these on map" -- "where
is THIS one" is worth asking without blanking every other layer for
context -- and hands over to the same banner, so Details reopens the list
exactly where it was.

Finally, a query's words may now match anywhere in a label, in any order, so
"coal node" finds "Coal Ore (Resource Node)". The parenthesised kind suffix
is a real part of those names and exactly how people say them, but no
contiguous substring spans it, so the old strict test could never reach past
it. Tighter matches still rank first, so "iron" keeps surfacing Iron Plate
ahead of Reinforced Iron Plate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map deliberately stops short of the right edge -- the altitude rail is
docked there -- but the panels that float over it are body-level siblings of
#map, so their left:50% was the WINDOW's center: half a rail-width (32px)
too far right, at every viewport size. On its own that drift is invisible;
under the search pill, which is genuinely centered on the map (#topBar
reserves the same rail width in its right padding), the filter banner sat
visibly off.

One --map-center-x for all four of them (filter banner, selection panel,
editor toolbar, editor hint), explained once where it's defined.

Also moves a group header's "× 28" badge from the right edge to right after
the building name: "Constructor × 28" is one phrase and now reads as one.
The label gives up its flex stretch and the badge takes over the
gap-filling, so the count column stays exactly where it was -- aligned with
every non-grouped row's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Search: collectable layer toggles, per-location "Show on map", word-order matching
A planning tool for "where should the belts/trains/power actually run".
Reachable only by typing its name in the search bar (a new "tool" catalog
entry, so it costs no permanent UI), it takes a set of points -- clicked
empty map, clicked buildings, the whole rectangle selection in bulk, or
typed X/Y metres -- and draws the shortest set of links joining them.

Two link styles, both exact and both O(n log n) (map/static/map/emst.js):

  Straight    Euclidean MST. Sits inside the Delaunay triangulation, so a
              sweep-hull triangulation cuts the candidate set from n^2/2
              edges to under 3n and Kruskal does the rest.
  X / Y only  Rectilinear (L1) MST -- NOT the straight tree with corners
              drawn on: changing the metric changes which points the optimal
              tree even connects. Sits inside the octant graph, and inside
              one octant the L1 distance collapses to a linear function of
              the far endpoint, so four Fenwick sweeps find all eight
              nearest neighbours at once.

Optionally one point is a DESTINATION, and a slider trades total network
length against how far every point has to travel through the tree to reach
it (Prim-Dijkstra: Prim with the key alpha * tree-distance + edge weight).
alpha = 0 is Prim, so still the exact MST; alpha = 1 is Dijkstra over a
candidate set augmented with the destination's own edges, so every point
joins it directly -- the exact minimum of summed trip distance. Between the
two it is a heuristic trade, and the panel says so rather than implying an
optimum that is NP-hard to find. On 5,700 real machines, 40% along the
slider costs 9% more network and cuts total travel by 47%.

Everything is computed in map pixels: the world projection is one uniform
scale with no rotation, so an optimal tree there is optimal in metres, and
"along X" on screen is "along X" in the world.

tools/check_emst.js is the gate. A short candidate set still yields a
spanning tree, just a silently longer one, so every result is diffed
against brute-force Prim over grids, circles, collinear runs, duplicates
and clusters -- plus, with a destination, that alpha=0 is still the MST,
that alpha=1 is the exact star, and that the trip distances the panel
reports match a walk of the tree it actually drew. It caught an inverted
in-circle sign on its first run. 100,000 points solve in about a second.

Also here, both found by using the tool:

- selection.js: selecting a PIN (vehicles, collectables, players, crash
  sites) marked the bare coordinate, which is where the tail points -- the
  pin itself, floating above it, looked untouched. Now ringed, with the
  same geometry the hover highlight already uses.
- The same object can no longer be added twice: points carry the object's
  save id, so clicking a 100m foundation's far corner counts as the same
  object rather than a second point. Clicking it again removes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Optimal network finder (EMST): shortest links joining a set of points
The map's chrome has no unit tests, so "did that CSS change break the
building dialog at 1024px" was only answerable by looking, which meant in
practice it went unanswered.

ui_shots.py captures 17 UI states (empty, loaded, each dialog, each tool
panel, three narrow viewports) and pixel-diffs them against a previous
run, so a refactor can be checked state by state:

    py tools/ui_shots.py --serve --out ui_shots/before
    ...change things, rebuild dist...
    py tools/ui_shots.py --serve --out ui_shots/after --baseline ui_shots/before

ui_behaviour.py covers what a screenshot cannot: that dialogs are real
modals, that the hover tooltip still paints above one, that Escape peels
exactly one layer, that opening a dock never resizes the map.

Both run against dist/ and take --serve and --headed. Their output is
gitignored; the baselines are local, since committing PNGs of the whole
UI would bloat the repo for little gain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chrome had grown one implementation per feature: four separate modal
dialogs, five progress bars, six list-row shapes, thirteen copies of the
same accent-button declaration, three private copies of el(). Each was
reasonable alone; together they meant "make the panels match" was a
manual job that was never finished. Everything also floated over the map
as free-standing cards, so a working session looked like a scatter of
unrelated windows.

Design tokens (map.css :root)
  The whole colour/type/spacing/radius vocabulary in one block. 19
  distinct font sizes -> 8 steps, 11 radii -> 5, 25 !important -> 0, and
  ~180 raw hex literals down to 17 genuine one-offs.

Primitives (ui.css + ui.js)
  .btn/.field/.dlg/.row/.bar/.toggleSwitch/.chev and UI.el/UI.dialog/
  UI.onEscape. ui.css loads BEFORE map.css so a feature rule of equal
  specificity can override a primitive without !important.

Native <dialog>
  The four modals use showModal(), which hands the browser the focus
  trap, focus restore, Escape, background inertness and top-layer
  stacking -- the last of which removed the hand-maintained z-index
  ladder entirely. Escape is no longer six document handlers coordinated
  by e.defaultPrevented with priority set by <script> order; layers now
  register explicitly. The hover tooltip became a top-layer popover,
  since no z-index can reach above a modal dialog.

Docked layout
  An app bar across the window, the layers dock pinned left, the tool
  dock (altitude rail + paste/network panels, one at a time) pinned
  right. The docks OVERLAY the map rather than taking grid columns, and
  #map always fills the area below the app bar. That is deliberate: as
  grid columns, every dock toggle resized the map and Leaflet's
  invalidateSize re-centred it, sliding the world ~141px sideways. Two
  rounds of compensation code were written and both reverted; not
  resizing the map removes the problem instead of correcting for it.
  See docs/dock-map-anchoring.md before changing this.

  The layers dock push-navigates between the category list and one
  category's contents instead of opening a second column, so browsing
  costs the map nothing and there is no half-empty pane.

Also fixed along the way
  - #logoButton was a button with a title, a pointer cursor and no click
    handler anywhere; it is a brand mark now.
  - The altitude rail's width was reserved even while hidden, showing as
    a dead black strip beside the map before any save was loaded.
  - The empty state was ~700px of void with the drop zone marooned at
    the bottom.
  - The search field kept its query after committing a result.
  - Delete in the selection bar looked identical to Copy.
  - No layout media queries existed at all; there are now breakpoints at
    1200/980/820/620px.
  - Accessibility: combobox roles on the search field, inert on the
    off-screen dock pane, aria-labels on icon-only buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CONTRIBUTING gains map.css / ui.css / ui.js in the project layout (with
the rule that new chrome reuses the primitives rather than restyling its
own), the two new tools, and how to run the UI guards.

dock-map-anchoring.md records why the docks overlay the map instead of
taking grid columns. Two attempts at compensating for Leaflet
re-centring the map on a dock toggle were written and reverted, the
second one worse than the bug it fixed, and the geometry-in-headless
testing that green-lit it was measuring the wrong thing. That is worth
writing down: without it the obvious "improvement" is to turn the docks
back into columns and walk straight into it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The active-filter banner sat visibly right of the search field above it.
Auditing every centred element across ten widths and four dock states
turned up two independent causes.

The map's overlay layer (hints, selection bar, filter banner) insetted by
each dock separately, so it centred on the VISIBLE map rather than the
window -- 109px out from the search field with just the layers dock open,
and worse with a tool dock. Insetting symmetrically fixed the centre but
then squeezed the layer narrower than a wide bar, at which point the auto
margins collapsed and it drifted again. The layer now spans the full
window exactly like #map and never changes size, so its centre is the
window's centre unconditionally. A bar wider than the gap between the
docks runs under one; the docks paint above it, so it is clipped rather
than overlapping, and at any normal width there is room to spare.

The editor toolbar was separately a quarter of the window out, from a
leftover alignToolbar() that measured the search box and wrote an inline
`left` in VIEWPORT coordinates. That was correct when the top bar was a
row of floating islands over a full-window map; once the toolbar moved
into an insetted overlay layer its containing block was no longer the
viewport, so the value was wrong by exactly the layer's offset. CSS
centring does the job now, so the function is gone.

While in here: overlay children are centred by grid `justify-self` rather
than `left: 50%` + a translate -- no percentage to resolve, and it lands
on whole pixels so the text stays crisp. And the app bar's pill labels
now drop at 1150px rather than 980px: the bar centres its search field by
giving both side clusters an equal share of the leftover space, which
only holds while each side's content fits its share, and with labels the
right-hand cluster stopped fitting at about 1150.

ui_behaviour.py gains a check that every centred element agrees with the
window centre across dock states, plus one for the altitude rail's 4px
track. Verified over 40 width/dock combinations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@valentinps valentinps closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant