Project › Area › Run: giving the data an owner, and reworking the studio around it - #69
Conversation
The run editor was a BAND: one row of labelled groups with rules between them, scrolling sideways, the action pinned at its right end. That shape was written for the 4rem foot it originally stood in, and it was correct there. It stopped being correct when the controls moved into a studio area -- `placement="area"` centred a low strip in a tall rectangle, so the height went to nothing and the vertical rules were doing work that separate surfaces do better. THREE MODULES. `runGraph.ts` is the model: which cards exist for a product, which edges join them, and where they sit before anyone moves them. `NodeCanvas.tsx` is the field: pan, zoom about the pointer, cards dragged by their headers, wires, and a dot grid that travels with the view so a pan reads as the eye moving rather than the cards. `BoardRunGraph.tsx` is the run editor built on both, holding the same controls the band held. THE EDGES ARE NOT A PIPELINE, WHICH IS THE ONE THING WORTH ARGUING ABOUT. A classification request carries a geometry, a date range with its cloud ceiling and a model; the period does not consume the area and the model does not consume the period. Drawing them as a chain would assert an order the request does not have. What is true is a fan-in -- several inputs, one run -- and that is what is drawn. The single real dependency is solar's product: irradiation reads a record and a season, siting reads two slope limits, and neither set means anything under the other product, so an edge from the product to each says which choice put it on screen. For the same reason NOTHING IS REWIREABLE, and the wire ends are filled marks that take no pointer events at all. The graph is the shape of a request rather than an arrangement someone chose, and a port that looked draggable and refused to drag would promise a freedom that does not exist. WHERE THE CARDS ARE KEPT, and where they are not. Placements go into the board snapshot beside `places` and `planePlaces`, because they are the same kind of value -- an arrangement made by hand, which is lost work if it is thrown away with the board. `ViewJSON` is opaque on the Go side, so this is a frontend field and not a migration. The VIEW is deliberately not kept: where the field is panned to is a reading position, and the canvas fits itself to the graph whenever the set of cards changes, so a reopened board shows the whole graph rather than wherever it was last looked at. Fitting stops the moment the view is moved by hand. TWO DEAD PROPS WENT WITH THE BAND. `onToolChange` and `mode`/`onModeChange` were declared on BoardRunBarProps and never read in its body: the tool tabs had already moved to the editor's header, where the handler also syncs `leftPanel` for the map's dock. A prop nothing calls is a second way in that does not exist. The mode is still passed and is dealt with separately. The editor's floor moved from 28rem by 3 to 24 by 14. The 3 was a band's floor -- a strip of controls needs its own height and nothing more -- and a field needs room for a graph to be a graph. The width floor moved DOWN because a narrow field can still be panned. `runBand.tsx` stays, with one caller instead of two. CanopyRunBar still stands in a foot and still wants the idiom, and the next workflow to take a foot should look like the last one -- easier to honour from a module than from another band's file. tsc reports no errors, the 289 frontend tests pass, and the production build completes.
…s honoured again `modeBlockedBy` says cumulative retention is a Random Forest procedure: under the other two models the temporal mode produces nothing to accumulate. ControlPanel has honoured that rule since it was written -- the option is disabled and the reason is on the pointer. The studio did not. Its mode sat in the editor header as a `StudioHeaderRadio`, which has no refused state at all: it draws a chosen half and an unchosen half and nothing else. So the studio offered a mode the run would not honour, with nothing to say about it. That is why this could not be a restyle. The control had to become something that can refuse. IT IS ALSO IN THE WRONG ROW. The header chooses which product is in view; the mode is an INPUT the run reads. Those are different questions, and with every other input now a card, the mode being the one left in the header made the header look like part of the form. It is a card, and the header keeps the tool tabs, which are navigation. THE MODEL NOW HAS TWO EDGES AND BOTH ARE TRUE. It is an input to the run like the area and the period, and it GATES the mode. The edge from the model to the mode card is that rule drawn rather than described, which is the standard the rest of this graph is held to: an edge exists where a real dependency does. `Choice` regains the `blockedBy` the band's version had and the port to the graph dropped. It is carried separately from `disabled` on purpose -- disabled is the whole card going quiet while a run is on, and a rule that refuses an option has something to say where a busy surface does not. Four imports left MapScreen with the radio: Clock, Map as MapGlyph, StudioHeaderRadio and MODE_OPTIONS. tsc reports no errors and the 289 frontend tests pass.
…modal goes The board could import an area and clear one, but not MAKE one. The only place to draw was the work map, and reaching it meant closing the board the area was being drawn for -- a round trip through the surface you are trying to add to. The answer at the time was a dialog holding a second map, which is a second map to explain and a dialog to dismiss. There is a planet in the arrangement now, so the drawing goes on it. THE DRAWING IS THE SAME `useAreaDrawing` THE WORK MAP USES, which is why this is a small change to a large-sounding one. That hook already carries the one-area rule, when a finished shape is reported and the sync with the copy held outside; it was extracted for exactly this and its own note said so. THE SHAPE IN HAND GETS ITS OWN SOURCE, not a feature in the catalog's. The catalog is what exists -- saved areas, drawn in the accent, pressable to open. A shape under the pointer is none of those: it is not saved, pressing it must not activate anything, and it has to be told from the ring it may be about to replace. Sharing the source would also put it under the catalog's click handler, so finishing a polygon would land on it as a press. White, which is what DrawMap already drew with: the colour says "in hand" where saved is orange. Stroked as well as filled, because DrawMap records that a closed polygon with no line of its own vanishes the instant it is finished, which reads as the drawing having failed. RELIEF, reusing components/map/terrain.ts whole. The DEM goes in UNDER the catalog: an outline says where work is and hillshade is the ground it is on, so shading over the ring would be the ground drawn on top of the thing it is under. Exaggeration 1 and off by default, both for the reasons that module already gives. It earns more here than on the work map -- a sphere shows the shape of a coastline on its own, but not why a valley is where it is. The elevation credit rides the readout whenever relief is on. terrain.ts states that rule and it is not decorative: the mosaic is assembled from national and mission datasets whose licences ask to be named, and naming it is also what keeps it distinct from the DEMs an analysis ran on. ONE COLUMN OF CHROME, at the top right. The bottom right is the camera's on this surface, and a second bar beside it would be two instruments for one hand. Relief leads because it is about the ground; the tools under it are about what is put on the ground. WHAT GOES: the pencil on the Area card, the `drawingArea` state, BoardAreaModal (212 lines) and DrawMap (196). Also the globe's "Open the work map here" button, which belonged to the globe screen removed on the previous branch and has had no caller since. The Area card is left with the two verbs that act on a shape it already has. The globe is not in any workspace preset, so drawing is now reached by turning an area into a Globe from the editor-type menu -- which is the studio's own model, and the editor's hint in that menu was rewritten to say the surface draws rather than only shows. tsc reports no errors, the 289 frontend tests pass, and the build completes.
An area drawn on the globe appeared on the globe and the Area card kept saying "none". The shape reached the application and was erased on the way in. TWO SURFACES RUN THIS HOOK AT ONCE. The work map stays mounted under the studio -- that is why the title bar withholds its telemetry rather than unmounting it -- and the globe now draws as well. Both are bound to the one area the application holds. So finishing a shape on the globe set that area, and the new value arrived at the WORK MAP as a polygon its own store did not have. The sync effect cleared the store to replace it; `draw.clear()` raises `change` with "delete" like a hand does; and the listener answered a synchronisation with `onPolygonDrawn(null)`. The saved catalog entry survived, which is the orange ring that was on screen, and the area itself was gone -- which is exactly the pair that was reported. The guard is narrow: while this hook is writing the store to match what it was given, the store has nothing to report. It is set for the whole replacement rather than per call, because cleared-and-not-yet-refilled is a state the hook passes THROUGH, and reporting from inside it would announce an empty store as an area that was removed. Restored in a `finally`, so a throw in addFeatures cannot leave a surface permanently unable to report. The caller's `clear` is a real edit -- someone pressed the bin -- so it still reports, and now does so explicitly rather than through the listener the guard silences. A removal nobody hears is a shape that stays in the application after it has left the screen. OLDER THAN THE GLOBE. Any path that sets the area from outside a mounted map would trip it: search, import, adopting a run's geometry. It needed two live surfaces to become constant, and that is what made it visible. The mechanism was arrived at by reading rather than by a runtime trace, and confirmed against the reported symptom. tsc reports no errors and the 289 frontend tests pass.
"none" in small type in the middle of a card is not where the eye goes first, and the area is what a run is blocked on when it is missing. The card says so now: an accent border, and the header glyph in accent-quiet. TWO LIGHTS THAT MUST NOT MEAN THE SAME THING. The run card was already the accent, saying "this is where the graph arrives", with its header FILLED. The area card says something else -- "this is carrying a value" -- so it is OUTLINED and never filled. A fill there would read as a second run button, and the difference between the action and a satisfied input has to survive both being lit at once. The accent is the only colour this chassis has to say either with, so the shapes carry the distinction rather than the hue. `CanvasNode.accent` becomes `tone: "action" | "held"`, so that distinction is declared in the type instead of living in a boolean the next reader would apply to the wrong card. ONLY THE AREA. It is the one input whose emptiness is a real state: a period has dates, a model is one of three, a mode is one of two. Lighting those would be a light that is always on. The glyph joins the border because the border is at the edge of vision when the eye is on the value in the middle of the card, and the glyph is the part of the header already being looked past. tsc reports no errors, the 289 frontend tests pass, and the build completes.
The drawing modal carried a search bar, and it went with the modal. The work map has one, but MapScreen withholds it for as long as the studio is up -- so between the two changes the studio lost every way of reaching a place by name, which was half of what that modal was for. REVEALED, NOT ALWAYS UP. The work map keeps its bar permanently because it has the width for it. A studio area may be a quarter of that wide, and a bar across the top of a sphere would cover the pole to answer a question that is asked once and then not again for a while. It opens from the column, on a magnifier. The column now reads in the order the work happens: find the place, light the ground, draw on it. IT OPENS TO THE LEFT of that column rather than under it. Under it is where the drawing tools are, and a panel that covered them would hide the controls a reader reaches for immediately after arriving somewhere. Bounded by the pane rather than given a width, so a narrow area gets a narrow bar instead of one that runs off the edge. ONE GEOCODER, which is what the change to SearchBar buys. It had hard-coded the work map's placement into its own body -- centred, 26rem, above the map's chrome -- so a second caller had to accept a bar wider than its pane or own a second copy of the search. Placement is a prop now, and its DEFAULT is the work map's own values, so that mount is unchanged by this becoming a choice. It also gains autoFocus, which a bar that is revealed needs and a permanent one does not. Zoom 14 on arrival, which is FlyToController's figure. That controller's export comment says it is shared precisely so there is one answer to how far the application zooms on arrival, and a second answer here would be a globe that lands closer or further than the map for the same search. tsc reports no errors, the 289 frontend tests pass, and the build completes.
A compass glyph said there WAS a tilt and not what it was. This control has always been the way back from a turned camera; what it could not do was report the camera it was undoing, so a reader who had orbited had to move the view to find out where they had got to. It is an attitude gizmo now, in the shape the reference instruments use -- an artificial horizon, Earth's navigation ball -- and in three parts: THE RING IS THE GROUND, seen from where the camera is. Straight down it is a circle; laid over towards the horizon it closes into a line. That is not a metaphor for pitch, it is the ground plane's actual projection, so `ry = r * cos(pitch)` is the whole of it. NORTH RIDES THE RING. At bearing 0 it is at the top, turning the camera carries it around, and the ring's compression carries it up and down with the tilt -- its vertical half taking the same cosine, so the mark sits ON the ring at every angle rather than crossing it. One mark and not four cardinals: at 22px the other three are three more things to resolve and none says anything the first does not. THE BAR IS THE VIEWER and does not move. Everything else turns under it, which is what makes the gizmo read as the world moving rather than the instrument. DRAWN BY MUTATION, NOT BY RENDER. The hook in this file returns a boolean and its comment gives the reason: bearing and pitch change on every frame of a drag, and holding them in React state would re-render the bar sixty times a second for two numbers nothing else reads. The angles are written onto the two elements that carry them, from the map's own rotate, pitch and move events. `level` is in the effect's dependency list because the control unmounts when the view is square, so coming back it has new elements to paint. THE BUTTON KEEPS ITS MEASUREMENTS. 2.125rem outside, with the drawing at 22px where the glyph was 16; the six pixels come out of the padding. MapChrome states what a control of a different size costs in a stack of them, and on the work map this one heads such a stack. Visibility is unchanged: it appears with the tilt it undoes and leaves with it. Level, the gizmo would be a plain circle with north at the top, which is what its absence already says. tsc reports no errors, the 289 frontend tests pass, and the build completes.
The surface switches MapLibre's attribution control off, and the mount says why: it renders its links as anchors, and in this WKWebView an anchor with target="_blank" opens nothing, while the licence asks the credit to be REACHABLE. The bargain was that the application would render the parts itself through BrowserOpenURL. It stopped holding up its end. The screen that rendered them was the globe destination, removed on the previous branch, and the comment at the mount still pointed at it -- so the studio's globe drew Esri imagery and credited nobody. Only the elevation mosaic was named, and only because relief brought its own rule with it. The parts now ride the readout at this surface's foot, in the title bar's order and with its separators, so the two surfaces credit one set of providers one way. Not `telemetry`: it is a sentence about ownership rather than a measurement, so it takes the sans face the title bar uses for the same line. IT WRAPS RATHER THAN TRUNCATES, which is where it departs from the title bar. That one may hide its credit below xl and clip what is left, because there the map is the whole window and the line is reachable on the surface that draws the imagery. A studio area can be a few inches wide, and a truncated credit in the only place it appears is a credit that is not given. `pointer-events-auto` on the credits alone: the line around them is a readout and must not take the pointer off the planet. tsc reports no errors, the 289 frontend tests pass, and the build completes.
… account The run log was in three places and in none of the right ones. THE PROPERTIES PANEL SWAPPED ITSELF FOR IT. That panel answers "what is this raster" about one a reader picked -- a question they asked and are waiting on -- and while a run was going it replaced its whole body with the log. The argument recorded for that was that the figures it displaced were stale and the live question was what the run was doing. Both halves are wrong: the reader asked for the legend, and the run's account belongs where the run was started. THE FOOT'S STRIP CARRIED A SECOND COPY of the same line, stage and percentage. Its own header claims the strip exists to give that column back -- and the column was never given back, because the panel still took itself over. So the strip was paying for a fix it had not made and repeating a line at the same time. Its middle zone goes; the button in it was inert anyway, since `onOpenLog` is optional and BoardSurface never passed it. THE CARD HAD A HAIRLINE AND A TOOLTIP. The run band kept the stage in a tooltip and the argument was sound about a single line: one that rewrites itself several times a second costs more attention than it returns. A card settles it rather than abandoning it -- fixed width, the line truncates, the words change and nothing moves. And a LOG is not that line: each entry is read once and stays, nothing is overwritten, and the stack is the only account of what the run did. `RunLog` moves here whole, bounded and scrolled, because a twelve- stage water run would otherwise grow the card past the graph it sits in. THE PERCENTAGE WAS 7900%. The sidecar emits a percentage -- `emit_progress(10, 'querying STAC catalog')` -- and StudioStatusBar has always read it that way. The band multiplied by a hundred to build the width of its progress hairline, which asked for 7900% of a bar and got a full one, so the error was invisible for as long as its only reader was a width. Drawn as a figure it said 7900%. Clamped now rather than trusted. What remains is two places answering two moments: the stack inline in the card while the run is going, and the same stack behind the card's Method panel once it is over -- which is what that panel was always documented to be for. `runLog`, `runRunning` and `runProgress` stop being threaded through the board, which no longer reads a log anywhere. tsc reports no errors and the 289 frontend tests pass.
A composition appeared under the current run without anyone asking for one, and it was not created: it had been in the gallery since the session opened. `activateProject` loads a project's overlays whole and says so deliberately -- "the gallery is always loaded so the compositions stay one click away in Overlay Tools; only the display is conditional". What decides whether one is RELEVANT is `scopeCompositionsToView`, and its rule for a composition with no run of its own is whether its extent meets the AOI on screen. That is the right rule for Overlay Tools and the wrong one for a run's asset list. Drawing an area over ground a stored composition happens to cover brought it into scope, and this loop then listed it under the current run as though that run had produced it. The defect was attribution, not scope. One with a `runId` has already been filtered to this run by the scoping. One without belongs to the project, and earns a place in a run's assets only by being the composition actually on the map -- which is the case this list exists to report. tsc reports no errors and the 289 frontend tests pass.
…t one Drawing a second area collapsed the board to one. The previous ground's rasters went, the catalog gained entries nobody made, and every area the map had moved on from was renamed "run-untitled". Four defects, one gesture, and they took several attempts to separate because three of them looked like the same duplication. THE DRAWING STORE REPORTED TWICE, AND THE TWO REPORTS WERE DIFFERENT SHAPES. Two surfaces run `useAreaDrawing` at once -- the work map keeps its own draw tool and stays mounted under the studio -- and the sync effect puts the area the application holds into each store. So drawing starts from a store that already has a polygon in it. terra-draw raises "update" in select mode while both are present, `emit` took the LAST polygon of the pair, and the application received a geometry it had not asked about and filed it as a new area. Two earlier attempts guarded by comparing geometries and neither could ever have worked, because the two reports do not carry the same shape. The store now refuses to report while it holds more than one: that is a state the hook passes THROUGH, never one to speak from. THE SYNC REPORTED ITSELF AS AN EDIT. `draw.clear()` and `draw.addFeatures()` raise the same events a hand does, so a shape arriving from elsewhere erased itself -- the shape drawn on the globe reached the application and was cleared by the work map being told about it. Guarded for the whole replacement, since cleared-and-not-yet-refilled is a state to pass through as well. RETENTION WAS TIED TO TWO GESTURES. `startNewClassification` and `backToAnalysesList` retained the outgoing run; every other way of changing the active area -- drawing, pressing one in the catalog, adopting a geometry, opening a project -- lost the work on it. It is an effect on the ground changing now, which catches the paths nobody has enumerated. The pair is held in a ref written during render, because the aoiSignature effects clear the products and reading state there would be a race against them. AND IT RETAINED THE WRONG OBJECT. `props.result` is the classification alone; water, both solar products and the flood envelope are joined at render time by `resultWithWater`. Retaining the classification kept the one product those effects do NOT clear and dropped the four they do -- the whole of what was being lost. `retainRun`'s guard also refused anything without a classification, on a comment that was true of the live area and of nothing else: the board has always been able to draw a retained water or solar run, since `legendByArea` reads those fields off any result. What was missing was a result reaching it. THREE IDENTITIES HAD TO BE MADE ONE. The board files its live area under the GROUND, `runId` reaching it is `result.run_id || "current"` -- which a standalone product never moves off the sentinel -- and retention keyed by the run. So the retained entry and the area it came from could not find each other: the entry arrived in the data tree as a run to add by hand, and the planes were not carried to it. Retention takes the ground now, the carry-over watches the ground, and one run is kept once however it was filed. THE CARRY-OVER ALSO ARRIVED A RENDER EARLY. Retention is an effect in App and the carry is an effect in a child of it, and React runs a child's effects first -- so it looked for the retained entry before it existed and had already forgotten which area to carry from. It is remembered at the moment of the change now and spent when the entry turns up. TWO CONSEQUENCES OF THE RE-KEYING, both mine, both found by reading the list the board actually holds rather than by reasoning about it: `groundOnBoard` asks `aoiOfRun` which ground each area is of, to keep the catalogued drawing from being listed beside the area already holding it. That map answers run id -> area id, and asking it about an area id returns nothing. Every ground with a retained run was therefore listed twice. The retained area's title came from the run list, and asking IT about an area id finds nothing either -- and `displayRunLabel` answers an empty label with "run-untitled", which is not empty, so the "Previous run" fallback beside it never ran. The scene tree lists ground and the data tree lists runs, a rule this file states and `resolveAoiDisplayLabel` repeats; the retained area now takes its ground's name the way the live area always has. A retained run can also be let go of. The board's X dropped one added from the picker and did nothing to a retained one, which lives in App -- tolerable while retention took two gestures, and not once a reader accumulates them by working. Its bin now asks the run list whether there is a row to delete, rather than testing an id prefix that stopped meaning that. tsc reports no errors, the 289 frontend tests pass, and the build completes.
Selecting between two areas emptied whichever one was selected. The area left behind kept its raster; the area arrived at came back with nothing, and switching again reversed which was which. The retained copy was being filtered out at exactly the moment it was the only thing left. Arriving at a ground makes it the live one, and the aoiSignature effects then clear the products -- they belong to the area being left. What remains is the retained entry, and `assetRuns` excluded any whose id was the live ground. That filter was mine, added a few commits ago against a duplication that was already prevented elsewhere. The live entry is added ONLY when it has assets of its own and is added FIRST, so `assetOf` prefers it whenever there is a live run to prefer; and `areas` filters `r.areaId !== live` on its own account, so no second row was ever going to appear. It cost the case it was not written for. tsc reports no errors, the 289 frontend tests pass, and the build completes.
Picking another area lifted it to the top of the tree and slid every row below it. Choosing between two therefore meant finding both again after each switch. The list is assembled live-area-first, then the catalog, then the retained runs. That order is true about how it is BUILT and says nothing a reader wants, and it makes the live area's identity -- which changes on every selection -- the thing the whole tree is sorted by. It is ordered by the catalog now: the order the grounds were drawn in, which nothing a selection does can change. An area with no catalogued ground -- a run loaded from the picker, an adopted geometry -- sorts after them and keeps the relative order it was assembled in. tsc reports no errors, the 289 frontend tests pass, and the build completes.
The data tree listed every retained run under one name, so a solar irradiation and a solar siting from different grounds could not be told apart. The name is right in kind: this tree lists runs, unlike the scene tree beside it, which lists ground. What was wrong is who was asked. `runs.find` is handed an AREA id, since retained runs began being filed under the ground they were over; it finds nothing, and `displayRunLabel` answers an empty label with the placeholder "run-untitled" -- which, not being empty, also stops the "Previous run" fallback beside it from ever running. The ground's name is the honest second answer, and `displayRunLabel` already knows how to make a run name out of one: its own legacy branch turns an area name into `run-<name>`. A solar run over "drawn 2" now reads "run-drawn-2". It matters most for the products that record no row of their own. Only WaterAnalysis carries a run_id on its payload, so solar and flood would otherwise stay anonymous however many of them a board held. tsc reports no errors and the 289 frontend tests pass.
…run on The run graph offered an area, a period and an Apply for both, which is not what either product is made of. It is not a regression: the band this graph replaced never carried them either -- it covered the period, the classification's model and the two solar branches, and nothing else -- so the gap was carried over rather than introduced. COMPOSITIONS gain four cards. Scene, because a composition is built from ONE scene and the list of them is a query over the area and the period that costs a round trip, so it is asked for rather than assumed. Composite, which gates the next card the way solar's product gates its own: an RGB composite reads three bands, an index composite reads one index, and neither set means anything under the other kind. Bands or Index, one of them, never both. And Stretch, the percentiles the ramp is fitted between. THE PERIOD DOES NOT FEED THE RUN HERE, it feeds the scene list, and the edges say so. Drawing it straight into Apply would assert that the request carries a date range; it carries a scene. SURFACE WATER gains its index. MNDWI, NDWI and AWEI as choices rather than a menu -- three short names that fit, and each title carries what it is computed from and who published it, because choosing between them is choosing between definitions. Those three were a private constant in WaterPanel, correct while that panel was the only surface offering them. lib/waterOptions.ts now holds them, following classifyOptions and solarOptions: two renderings of one set of choices is a design decision, two copies is a defect waiting for someone to add an index. Every value is the map screen's own state passed straight through, so the panel and the graph cannot come to disagree about what the next run will be. tsc reports no errors, the 289 frontend tests pass, and the build completes.
Saving refused with "none of these areas is a saved run yet" on boards that plainly had one, and the message then sent the reader back to do what had just failed. TWO HOLES, BOTH OPENED BY KEYING RETENTION TO THE GROUND. Members are runs, and the filter dropped any whose id is an area id -- sound while that meant "a catalogued drawing with nothing on it", and wrong once it meant "every area but the live one". `aoiOfRun` already answers which ground a run is of, by `aoi_id` or by ring equality for rows written before that column, so it is inverted here rather than guessed at. A ground with no run behind it still resolves to nothing and is still dropped. The live area was the one place not getting that lookup at all. Its id is `result.run_id || "current"`, and only a classification fills that field: solar, water and flood leave it on the sentinel. So the commonest board of all -- draw an area, run one product, save -- had exactly one member and lost it. Verified against the store: today's solar runs carry a correct `aoi_id`; the row existed and the result simply did not know its own id. THE MESSAGE WAS ALSO WRONG. It said "run something" to a reader who had just applied a composition, and a composition never becomes a run: RenderComposite records no row and says why -- it is a rendering, reproducible from its request, where the other products are measurements returned to and compared. It is saved as a project overlay, which is why it survives without a board. The refusal now says that, names the analyses that would give the board something to arrange, and states that nothing was lost. The ground-to-run inversion is a workaround for a gap in the Go layer, and the approved ownership plan removes it: once every persist function returns the run id it created, the live area has a real one and this lookup can go. tsc reports no errors and the 289 frontend tests pass.
Five kinds of run reach the database and one of them could say so. Everything that has gone wrong around identity this week traces here. `saveRun` has always returned the id and its comment has always said why the caller needs it: to tell the frontend which run it is now looking at. Six of the seven wrappers threw it away — water, solar, the two solar rasters, the energy model, wind and flood — so only a classification came back knowing its own name. WHAT THAT COST, downstream, in the code this repository has been repairing by hand: the studio's live area resolves `result.run_id || "current"`, so for those products it stayed on the sentinel; a board therefore could not record the run it was plainly showing, and the save path grew an inversion of `aoiOfRun` to guess the run back out of the ground it was over. A composition applied over a solar run was filed against no run at all, and then reappeared under a later one by extent. Each of those was treated as its own defect. They are one. THE FIELDS WERE DECLARED AND NEVER WRITTEN. `WaterAnalysis.RunID` and `SolarAnalysis.RunID` have existed since they were written, with comments explaining that an empty value is `saveRun` withdrawing its claim to have saved — a withdrawal that had nowhere to go, because nothing ever filled them. `WindAnalysis`, `FloodAnalysis`, `EnergyModelAnalysis`, `SolarTerrainAnalysis` and `SolarSitingAnalysis` had no field at all. They have one now, and every persist path stamps it after the row is written, for the reason the classification path already states: the stored copy is marshalled inside `saveRun`, so a result carrying its id would put the id inside its own row. TWO MORE DOORS TO THE SAME PROBLEM, in `LoadAnalysis`. The wind and water branches returned a result with no `RunID` while the solar, flood and classification branches beside them set it. Reopening a saved wind or water run handed the frontend the same sentinel the live path produced. A run read back from its own row is the one case where its id is never in doubt. `SurfaceModel.RunID` goes the other way and is REMOVED, along with `SurfaceModelRequest.ProjectID` and `.AoiID`. `AnalyzeSurfaceModel` records no run and argues why: the surface is the ground the measurements were made on, static and reproducible from the polygon alone, so a row would record nothing the request does not already say. A field that can never be filled is worse than no field. Removing the two request fields also removes an outlier rather than fixing it — `MapSurface` was passing `project_id: ""` and `aoi_id: ""` because nothing read them. The five new fields carry LINE comments and not block ones, because `frontend/scripts/check-types.ts` parses these structs and refuses a `/*` it cannot read a JSON name from rather than skipping the field in silence. That script is also what required the matching `run_id?` on the five TypeScript interfaces, and what confirmed the two sides agree afterwards. First stage of the approved Project › Areas › Runs plan, and the one that unblocks deleting the board's workarounds later. Purely additive apart from the surface-model removal; no consumer has to change yet. gofmt clean, go vet clean, `go test -race ./...` passes, tsc reports no errors, 289 frontend tests pass, and check-types reports every mirrored struct agreeing. golangci-lint is not installed on this machine, so that half of the gate was not run.
An area was a JSON array inside preferences.extras_json.saved_aois: a list belonging to a user, referenced by inference_runs.aoi_id and projects.area_id, and validated by nothing. Nothing could query it -- there is not one `WHERE aoi_id = ?` in this package, because there was no column to put on the left of it. So a run pointed at something that might not exist, the project it landed in was whichever one happened to be active, and every reader that needed to get from a run back to its ground did it by comparing polygon rings. `areas` is now a row: id, project, user, name, geometry, notes, timestamps. The schema lives in areas.go rather than store.go for the reason whiteboardSchema does -- the statements that make a subject belong with the subject -- and it is applied unconditionally like every other CREATE TABLE IF NOT EXISTS here, since the version records how far the gated steps have run and says nothing about which tables exist. user_id is carried as well as project_id and that is not redundancy: foreign keys here are declared and never enforced, so every reader scopes by user in the same statement rather than checking first and trusting after. Without the column each of those would need a join to projects. The invariant is established once, where the row is made. THE LINKS: inference_runs.area_id, project_overlays.area_id, whiteboards.project_id, projects.last_area_id. NOT NULL DEFAULT '' because SQLite refuses ADD COLUMN NOT NULL without a default -- and the empty string is unreachable only because the discard runs first, which is why the order inside the gated block is not arrangement but meaning. schemaVersion 2 to 3. THE DISCARD IS NOT A MIGRATION AND DOES NOT PRETEND TO BE. There is no rule that turns a run naming its ground through a JSON array into a run inside an area without inventing which area it was of, and a wrong answer there is worse than an empty database, because it reads as work rather than as absence. Users, sessions and preferences survive: a theme and a window layout are not domain data, and losing them would be a second, unnecessary loss. The copy is taken BEFORE the first delete, and that order is the whole safety property. VACUUM INTO is the mechanism the backup path already trusts, and it cannot run inside a transaction, which is also why the deletes follow it rather than sharing one. Asset directories are moved aside rather than removed, following the convention RestoreBackup uses: the way back from an irreversible step should be a rename, not a restore. An ExportBackup archive was the alternative and is the wrong tool -- it strips password hashes and session tokens, right for a file a user mails themselves and wrong for a rollback the application takes on its own, and it re-compresses every asset when the assets are exactly what the renames keep. NOTHING TO DISCARD COSTS NOTHING. Every fresh install reaches this step, since a database created a moment ago reports version 0. Unguarded it left a snapshot and a runs.replaced-* directory beside a database that never held a row, which reads as damage where there was none. The store tests caught that; the guard is a row count. TESTS: `TestLegacyDatabaseWithoutAVersionMigrates` now asserts the opposite of what it asserted, deliberately. It promised that pre-version rows survive migration; they no longer do, and the promise is now that the domain is emptied, the account is not, and a copy is left beside the database. Two new tests cover the halves the rows cannot show -- that assets are set aside rather than removed, and that the catalogue leaves the preferences blob while the theme beside it does not. Nothing reads the new columns yet. The application behaves exactly as before, over an empty database. gofmt clean, go vet clean, `go test -race ./...` passes, tsc and the 289 frontend tests unaffected. golangci-lint is not installed here, so that half of the gate was again not run.
…its runs The table landed last commit with nothing able to reach it. This is the surface: CreateArea, ListAreas, GetArea, UpdateArea, DeleteArea, ListRunsByArea, and the cascades that make the chain hold. CREATEAREA READS THE PROJECT IN THE TRANSACTION IT WRITES IN, for two things at once: that the project exists and is this caller's, and whose user_id the area carries. That is where the invariant "an area's user is its project's user" is established, and establishing it once is what lets every reader afterwards scope by user in one statement instead of joining to projects a dozen times. THE PROVISIONAL NAME IS MINTED SERVER-SIDE. "drawn", "drawn 2", "drawn 3" was computed in the frontend from its own copy of the catalogue, and two draws reported in one React batch read the same stale list and produced two areas with one name between them -- a defect this repository has already fixed twice from the other end, and which cannot recur once the count comes from the table. It is numbered per project: two fields called "drawn" in two projects is not a collision, and numbering across them would open the second project at "drawn 4". CASCADES ARE WRITTEN WHERE THE DELETE IS, because foreign keys here are declared and never enforced. DeleteArea takes its runs, their rasters, the board members naming them and the compositions filed under it. DeleteRun stops leaving references dangling: the board member goes, and an overlay's run_id is CLEARED rather than the overlay deleted -- the composition is still a real raster of real ground, and its own comment already defines empty as "belongs to the project". GetWhiteboard's Missing reporting stays; it is the honest answer for a gap this code does not create. DELETEPROJECT NO LONGER DETACHES ITS RUNS, and that is a destructive change to a control that already read as destructive. `project_id = NULL` was right while a run could stand on its own; under the chain there is nowhere to detach to, since a run with no project is a run of no ground. It takes the areas, the runs, their rasters, the boards of that project and its overlays. The test that asserted the old promise now asserts this one, with the reasoning it replaced written down -- it was not wrong, its premise was. LISTRUNSBYPROJECT WAS MISSING THE AREA. It selected sixteen columns where ListRuns and GetRun select the whole row, and the one it omitted was the link back to the ground: a run read through the project list came back claiming no area, while the same run read any other way named one. The hub reads runs through here, which is exactly why grouping them by ground was impossible from the screen where it matters most. InferenceRun gains AreaID beside AoiID. Two names for one thing is the confusion this change exists to remove, and carrying both is a transition, not a design: AoiID goes when the frontend stops writing it. Five new tests cover what the database cannot enforce for itself -- the user taken from the project, another user's project reported absent rather than forbidden, per-project numbering, the delete cascade including rasters and board members, and the run listing that had been dropping the area. gofmt clean, go vet clean, `go test -race ./...` passes. golangci-lint is still not installed here.
The catalogue needed no API: it was a JSON array inside a preferences blob, so the frontend held the whole of it and the store never saw an area at all. That is exactly what let a run's aoi_id point at something nothing could check. An area is a row now, and a row is reached by asking. `app_areas.go` is thin over the store, like app_projects.go and app_whiteboards.go beside it: resolve the effective user, hand through. The rules stay where a test reaches them without a running application -- that an area belongs to a project, that its user is the project's, that deleting it takes the runs of it. CreateArea takes an optional name and usually gets none, because the store mints "drawn", "drawn 2" from what the project already holds; a caller with a better name -- an imported file -- passes it and keeps it. DeleteArea's doc says what changed about it rather than only what it does: removing an entry from the old JSON array left the runs alone, since nothing linked them. It does not any more, and the caller is the one that has to ask first. The generated bindings are regenerated with it, since they are committed. Only the three runtime files' permission bits came back different, which is generator noise and was put back. gofmt clean, go vet clean, Go and frontend suites pass. golangci-lint still absent here.
A, B and C were geojson files beside the binary, offered as a SECOND way to name a ground: a request carried either an `area_id` selecting one of them or a polygon, and every reader in the runner had to handle both -- twelve branches across seven analyses, each resolving the same question two ways. That second path is one of the duplications the ownership work exists to remove, and it is the one that made `area_id` ambiguous: the column on a run means the catalogued ground, the field on a request meant an example. WHAT THEY CARRIED BEYOND GEOMETRY, and why none of it is lost: Two pinned Sentinel tiles, T22JBT and T21JZN. Left empty the STAC catalogue returns whichever tile covers the ground, which is right for every field and was only right for those three by coincidence. A local MapBiomas raster, which the comment in Predict described as the reason "soja retention" was unavailable for a drawn polygon. That has not been true for a while: terra/mapbiomas.py resolves an empty path by fetching the Brazil COG window, and the classifier checks polygon_in_brazil before asking. The local raster was a shortcut, not the capability. Checked in the sidecar rather than assumed, because removing a capability quietly is the failure that matters here. WHAT GOES: the three files, Runner.ListAreas, Runner.loadArea, Runner.AreasDir, the areasDir field, App.ListEmbeddedAreas, the "Areas" row of the environment report, and the twelve request branches -- each now "no polygon, no run". THE PACKAGING WOULD HAVE BROKEN. Both release scripts copy `areas/` into the bundle unconditionally, and the shell one runs under `set -e`, so the first release after this commit would have failed at the copy rather than at anything a reader could connect to the change. Removed from both, and from the workflow comment that lists what LITE ships. The frontend stops fetching them and its `areas` list is now permanently empty. `activeExample` and the state around it survive one more step: they are threaded through about a hundred places, and unpicking that here would hide the removal inside the unpicking. Read from an empty list every one of those answers "no example", which is the behaviour the application now has. The runner test loses the half that asserted the three areas and keeps the half about the paths a runner resolves, renamed to say so. STILL STALE, and named here rather than left to be found: docs/USER_GUIDE.md tells a reader to "Select embedded area A", and docs/ARCHITECTURE.md lists "embedded area or GeoJSON polygon" as the first resolution step. Both are rewritten in the frontend stage, when the flow that replaces them exists to be described. gofmt clean, go vet clean, Go and frontend suites pass, package_release.sh parses. golangci-lint still not installed here.
…that
The second way is gone from the code, not just from the data. Removing the
three example geojson files left `area_id` on every request struct and
`activeExample` on every screen, reading a list that could no longer fill: the
plumbing for a choice with one option left.
WHAT `area_id` ON A REQUEST DID, and why it could not stay: it selected an
example, and `aoi_id` on the same struct named the catalogued ground. Two fields
one letter apart meaning different things, one of them dead. Off both sides now
-- 12 request structs in Go, 11 interfaces in types.ts -- and `aoi_id` is next
to take the name that is now free.
A DEFECT THIS CLOSES. saveRun wrote `{"area_id":"A"}` into polygon_geojson when
a request came with an example instead of a shape: a geometry column holding
something no reader of geometry can parse, decoded on the way out by
parseRunPolygon looking the id up in a list. Both ends are gone, and the
schema-3 migration already emptied every run that could still carry it.
parseRunPolygon returns a geometry now instead of a geometry-or-example pair.
THE READERS, each collapsed to the branch that survives:
`usesExampleArea` chose between the two ways at 12 call sites; every one of them
now requires a polygon and says so -- "Draw an area on the map first", where it
said "draw, search, or load an example".
`resolveProjectGeometry` took a project and a list of areas to resolve
`projects.area_id` against. It takes the project.
`aoiSignature`, `visibleAoi`, `areaLabel`, `analysisPolygonGeoJSON` and
MapSurface's `aoiGeometry`/`aoiName` each had a custom-first-then-example
precedence. There is one term left, so there is no precedence.
LulcSection showed a documented crop rotation looked up by area id from a table
of three, written down because those three fields were known. Nothing supplies a
rotation for a field a user draws, so the panel could only ever have appeared
for A, B and C.
`syncProjectAoi` wrote the example id into `projects.area_id`. It writes "", and
that column stops being written at all -- it is dropped when its last reader in
the hub goes.
The `Area` type itself, both the Go struct and its TS mirror, described an
embedded example: id, label, kml_name, approximate, mapbiomas. Nothing imports
either. Not to be confused with store.Area, which is the row a drawn ground is
now kept in.
`activeExample` is threaded out of App, MapScreen, EnergyScreen, FloodScreen,
MapSurface, ControlPanel, AoiSection and BoardRunGraph.
gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend
tests pass, check:types reports every mirrored struct agreeing.
The catalogue lived in preferences.extras_json.saved_aois: a JSON array the frontend minted ids for, prefixed `aoi:`, named, renamed and deleted entirely in the browser. Nothing in Go had ever seen an entry. That is the root of the count the hub was reporting -- a run's aoi_id pointed at a value no query could resolve and no delete could cascade, so 58 runs from several fields sat inside one project, because the only thing that knew what an area was could not say which project it was in. WHAT REPLACES IT. `ListAreas(projectID)` on opening a project, `CreateArea` on drawing, `UpdateArea` on renaming, `DeleteArea` on deleting. lib/areas.ts holds the view the screens read -- id, name, parsed geometry, created_at, notes, run_count -- and lib/savedAois.ts, with newSavedAoiId, nextDrawnName, parseSavedAois and createSavedAoi, is deleted: every one of them existed to give identity to something that had no row. DRAWING NOW REQUIRES A PROJECT, which was the decision behind this. There is nowhere to put an area without one, so createArea says so rather than inventing somewhere, and the outline is cleared with the refusal -- a shape on the map that the area card calls "none" is the disagreement this change exists to end. Importing a file refuses the same way, which is why that handler moved from App into AppBody: it needs the project id, and it was written where it was when an area belonged to nothing. THREE DEFECTS THAT GO WITH THE STORE: Renaming patched the local list and stopped. The name was gone at the next start, and every run recorded under it still carried the old one. It is an UPDATE now. Deleting removed an entry and left the runs -- nothing linked them, so nothing could follow. DeleteArea takes the runs and their rasters, so this asks first, naming the count. The provisional names were minted in the browser from a list that a second report in the same React batch had not yet changed, which is how two areas arrived called "drawn 2". The store numbers them in the transaction that writes them, against what the project actually holds. Creating a project no longer copies the map's shape into projects.polygon_geojson -- a project holds areas, and one shape of its own cannot describe a reader working several fields. handleCreateProjectFromAoi is handleCreateProject. TWO NAMING NOTES. `savedAois`/`activeAoiId` are `areas`/`activeAreaId` throughout, the vocabulary the hierarchy uses. Inside BoardSurface the prop is `catalogAreas`, because `areas` there already means the stacks on the board. And `isSavedAoiId` is gone: it tested the `aoi:` prefix, and store ids are bare UUIDs like run ids, so telling them apart is a membership test against the catalogue. The request field is still `aoi_id`; it now carries a real area id. Renaming it to `area_id`, which the previous commit freed, is its own step. gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend tests pass, check:types clean.
A run carried aoi_id AND area_id, one letter apart, meaning the same thing against two different catalogues. That pairing was a transition, declared as one in InferenceRun's own comment; this ends it. aoi_id is gone from the request structs, from the row, and from the table. WHY THE COLUMN IS DROPPED RATHER THAN LEFT UNWRITTEN. Nothing writes it, and the version-3 step already emptied every row that could hold a value, so what would remain is a column that always reads '' -- which is the same argument areas.go makes for having no position column: one nothing writes will disagree with the table one day. Schema 4 drops it. SQLite has done DROP COLUMN since 3.35 and refuses on an indexed column; this one has no index. `dropColumn` asks the table rather than trusting the version, for the reason addColumns gives at length: a database created fresh by this build never had aoi_id, so an unconditional drop would fail on exactly the files that are already correct. Any other failure propagates -- a build that believes the column is gone while it is still there is the disagreement the drop exists to end. THE ADD IS DELIBERATELY LEFT IN PLACE at version 1, which reads as pointless and is not: a database at version 0 passes through every version's statements on its way up, and removing that line would leave the drop with nothing to drop on the files that most need the migration to be coherent. Renamed through: AoiID -> AreaID on eight request structs and on savedRun, aoi_id -> area_id in types.ts and at the eight call sites, and the board's aoiOfRun now reads r.area_id. TestRunAoiIDRoundTrip becomes TestRunAreaIDRoundTrip -- it asserted that an id like "aoi:abc" round-tripped, and nothing can write one now. Beside it, TestAoiIDColumnIsDropped checks the table rather than the intent, on a fresh database and on one migrated up. Comments that named the old field are corrected where they described the code and kept where they describe the history, per the standing rule. gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend tests pass, check:types clean.
A whiteboard belonged to the user and to nothing else. The menu therefore offered every board ever saved regardless of which project was open, and SaveWhiteboard checked only that a member's run belonged to the same USER -- so a board could hold runs from several projects, and one project's board could be filled with another's work by opening it and saving. whiteboards.project_id gets a writer and a reader. ListWhiteboards takes a project and filters on it; SaveWhiteboard writes it and refuses a member whose run belongs to a different one. Neither is enforced by the database: foreign keys here are declared and never turned on, so these two statements are the whole of it, which is why both now have a test. A BOARD SAVED BEFORE THIS COLUMN EXISTED carries no project, and is listed alongside the open project's own rather than hidden -- a board that cannot be found is indistinguishable from one that was lost -- and re-saving it is not refused. The project check applies only when both sides name one. An empty projectID still returns everything, which is what the storage and backup views ask for and what a board menu never does. With no project open the menu is empty rather than complete: listing every project's boards in front of a reader who has said nothing about which project they mean is the offer being withdrawn. WHAT DID NOT COME OUT, and why. The plan expected `runOfGround`, the `"current"` branch and `liveAreaId`'s second question to go once every product returned a run id. They stay: the sentinel had two causes and only one is fixed. Every product returns its id now, but a run whose save FAILED returns none -- that is saveRun withdrawing its claim, deliberately -- so the ground still has to answer for it. And retained areas are keyed by the ground either way, so the inversion is still what resolves them. The comments there asserted the fixed half in the present tense -- "only a classification fills that field", "a standalone product leaves it on the sentinel". Corrected to say what is true now and what the case that remains actually is, per the standing rule on stale comments. `liveAreaId`'s sentinel job is restated the same way: it no longer names an example area, because there are none. gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend tests pass, check:types clean.
The last step, and the one that makes the model visible: without it the hierarchy existed in the database and nowhere a reader could see it. The hub grouped by project alone and put every run of it in one flat grid. That is the shape that let a project holding several fields read as a single subject -- fifty-eight runs under one name, with nothing saying which ground each was over. The project's tab now opens on its GROUNDS, each with the count of runs measured on it, and opening one lists those runs under a way back. A ROW FOR THE RUNS OF NO GROUND, shown only when there are some. A run restored from a shape that was never made an area carries no area_id, and without a row to select it would be listed nowhere: in the database, absent from the only place that lists runs. An empty category is a question a reader has to answer to learn there was nothing in it, so it does not appear when the count is zero. Leaving a project clears the selected ground with it. An area belongs to one project, so a selection carried across matches nothing: an empty list under a heading naming a field that is not in this project. THREE PIECES OF TEXT THAT HAD BECOME FALSE, corrected rather than left: The project header printed `label || area_id || "Custom polygon"` under the word AOI -- one shape per project, the model being replaced. A project working a dozen fields showed a single line naming one of them. It says how many grounds the project holds, and those three columns lose their last reader. The delete-project dialog promised "runs stay on disk but become unassigned". That was true when DeleteProject detached them; it takes them now, with the areas and their rasters. A dialog that promises the opposite of what the button does is worse than no dialog. The tab said "Runs" and opened on runs. It opens on areas. DOCUMENTATION. USER_GUIDE section 2 described a project as grouping "an AOI", and section 3 offered "Areas A / B / C" as the first way to pick one; the suggested first run said "Select embedded area A". Rewritten around project > area > run, including that a project is required before drawing and why. ARCHITECTURE dropped the `areas/` row and now says the polygon is the only way a request names a ground. API.md loses ListEmbeddedAreas, gains the areas and project-scoped board methods, and its PredictRequest example no longer sends `area_id: "A"`. TROUBLESHOOTING said the embedded areas could use a local MapBiomas raster; it now says what the fetch actually does. Four files still called the map library Leaflet, which it has not been since the MapLibre move -- corrected in the docs, left alone in the code comments that describe what Leaflet used to do, because those are history and read as such. gofmt clean, go vet clean, Go suite passes, tsc clean, 289 frontend tests pass, check:types clean.
The three columns that made a project carry one shape -- polygon_geojson, area_id and label -- lose their last readers and their place in the table. They were written from whatever happened to be on the map while the project was open, so a workspace holding a dozen fields still showed one line naming one of them, and the run made in it inherited that line rather than the ground it was over. That is the confusion this whole change set exists to remove; this is the part of it that was still in the schema. WHAT REPLACES THEM is last_area_id, one question instead of three: which ground to resume on. It is a cursor, not a property -- DeleteArea clears it on every project pointing at the area, and an id that resolves to nothing is read as no cursor rather than as an error. Schema 5 drops the three. UpdateProjectAOI, which wrote all three from the map, is SetProjectLastArea. `syncProjectAoi` in the frontend goes with it, and its three callers each had a different reason to exist: - After a composition and after a classification it wrote the map's polygon onto the project. There is nothing to write; the refresh beside it stays, because the counts do change. - `applyAoiRename` wrote the new name to projects.label, so renaming a second field overwrote the first. It renames the AREA now, through the store, which is where the name belongs and what makes it survive a restart. With nothing catalogued on the map -- a geometry adopted from an opened run -- there is no row to rename and the label stays a session-local display name, which is what it was for every shape before areas existed. OPENING A PROJECT resumed on its polygon. It resumes on last_area_id, falling back to the project's first ground, and the composition-in-view test reads that same shape rather than the project's. THE HUB CARD DREW THE PROJECT'S OUTLINE and could not honestly go on doing it. It shows the count of grounds instead of a thumbnail of one of them: drawing them all would be a query per card in a grid, and a dozen outlines at 64px say less than the number does. ListProjects counts areas the way it already counted runs and overlays. Project search matched `p.label` too; a project has grounds, each with its own name, so it matches the name. `resolveAoiDisplayLabel` took `projectLabel` as its second candidate. It takes `areaName`, resolved from the run's own area. `resolveProjectGeometry` is `polygonFromRow`. It stopped reading projects two commits ago -- both callers pass RUN rows -- and a helper named for a row it no longer touches is the kind of thing that sends a reader looking for a project geometry that does not exist. TestProjectAOIUpdate is TestProjectLastAreaCursor. It asserted that a malformed polygon was refused and left the previous AOI intact; there is no polygon on a project to refuse, so what is left to pin is that the cursor is stored trimmed, does not disturb the project's own fields, and can be cleared. gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend tests pass, check:types clean.
FOUND ON THE RUNNING APP, not reasoned about. After the previous commit the developer's database reported user_version = 5 while projects still declared polygon_geojson, area_id and label. The gate had closed over three columns that were never dropped and could never be dropped again: the file said the work was done, and nothing would reconsider it. HOW IT HAPPENED. schemaVersion was raised to 5 in one edit and the drop it stood for written in the next. `wails dev` rebuilt in between. That build opened the database, found at = 4 < 5, had no step to run for the new number, and recorded 5. That is not a mistake in the drops, and fixing it by hand would leave the shape that produced it. It is what a gate on a number does when the number can be raised without the work -- and every file opened by such a build is permanently wrong in a way no later version bump repairs, because the gate it needs has already passed. THE FIX IS THE ARGUMENT addColumns ALREADY MAKES: "the table is asked rather than the version trusted, because on every database already in the field the two disagree." dropColumn asks the table too, so the drops run at every Open. The cost is four pragma_table_info queries; what it buys is a file that ends in the shape this build believes it has, whatever its version says. The version goes back to recording what has been considered rather than standing as the only evidence of what exists. The developer's database repaired itself on the next boot, which is the behaviour this is for. TestDropsRunOnADatabaseAlreadyAtThisVersion builds exactly the broken file -- the four columns present, the version already current -- and asserts Open removes them. Checked that it fails when the gate is put back: all four columns survive. gofmt clean, go vet clean, Go suite passes.
… starts The button greyed out with no raster on screen and no AOI, saying "Nothing to work on: draw an area or run something first". So the only way into the studio was to do the work outside it first -- the opposite of what that surface is for, since it carries a globe to draw on and a run graph to run from. THE RULE WAS RIGHT WHEN IT WAS WRITTEN. The board could only ARRANGE runs: a surface mounted over the map with nothing on it and no way to put anything there was a dead end, so the entry was refused. Drawing and running moved onto it, and the refusal outlived its reason. The rule was already admitting the case. `boardAsked` existed as an exception for "a studio opened by name -- a saved whiteboard, or a session that opens on it by preference -- stands with nothing on it, because that is the state it necessarily starts in and its own run band draws an area and starts a run without leaving it". That is the general case, not an exception, so the whole condition goes: boardOpen is the state and nothing else. WHAT ELSE THAT CLOSES. The old condition read visible layers, so the sidebar's eye could hide every one of them and unmount the surface holding the control -- taking the search field, the overlay tools and the way back in with it. Two clauses had been added over time to patch that; none of them is needed once the board stays up because it was opened. boardHoldsOtherAreas existed only to keep the board mounted when the map's own result was discarded while another area was on it. Nothing closes the board now but the toggle, so it has no readers and goes. An empty studio is not blank: BoardSurface always mounts the live area, so the globe is there to draw on and the run graph is there to run from. Drawing still requires a project, and refuses with the reason, as it does on the map.
With the studio open, nothing on screen named the project. The title bar withholds its project switcher there, and the reason it gives is that the studio "carries its own data-block naming the board that is loaded" -- which it does, and which is not a substitute: a board is not a project. THAT MATTERS MORE IN THE STUDIO THAN ANYWHERE ELSE. It is where areas are drawn and runs are made, and both are filed under the active project. Work landing in a project the surface never named is the failure this whole change set exists to remove -- fifty-eight runs from several fields inside one project, because `activeProjectId` is sticky session state and nothing on screen said so. Every other surface names it. The studio was the one still reaching that failure, and it is the surface that produces the most. A CRUMB, NOT A SECOND SELECTOR. The title bar's concern is real: two dropdowns on one row, each saying what is open and meaning something different by it, is worse than one. Written as `project > board` the relation is stated instead, and there is still exactly one control on the row. With no project open it reads "No project", drawn as an absence rather than as a project called that. It carries more weight here than in the title bar: without one the studio can neither draw an area nor file a run, so the crumb is the reason the next attempt will be refused. The title bar's comment is corrected rather than left: it stated a reason that was only half true.
…ced it
Reported from the window: a solar run over a drawn area, its raster on the
board, and Save refusing with "none of these areas carries one yet". The
database was right -- the run had its project, its area and the label
run-drawn-20260830-172632. The board could not see it.
WHERE IT WAS LOST. `resultWithWater` spreads `props.result ?? EMPTY_RESULT`, so
`run_id` came from the classification and from nothing else. A standalone
product leaves `props.result` null, so the merged object described a solar run
and carried no id for it; the board reads `result.run_id || "current"`, got the
sentinel, and its own filter drops members on the sentinel.
Stage one gave all seven products a run id precisely so this could stop
happening, and nothing on this side picked it up: the standalone handlers read
`res.run_id` solely to decide whether their toast said "(saved)". All seven set
`currentRunId` now, and the merged result carries it.
That fixes a second reader for the same reason. Saving a composition writes
`run_id: currentRunId ?? ""`, so a composition applied over a solar or flood run
was filed against no run at all -- the defect its own comment describes.
THE NAME BESIDE IT, "run-untitled", was a separate fault in the same row and
would have survived the fix. `displayRunLabel("")` answers "run-untitled", which
is a value rather than an absence, so the `|| title || "Unnamed area"` chain
after it could never run: every row whose run the list could not answer for was
called the same thing, whatever the map called its ground. The run label is
asked for only when there is one. Still reachable two ways -- the list has not
refreshed, or the save was refused and there is no id to find -- and in both the
ground is the honest answer.
Corrected a comment that claimed "only water records a run_id on its payload, so
solar and flood would otherwise be permanently anonymous". True when written,
and the reason that fallback exists; all seven carry one now.
It had three. The database, the store and the bindings said `whiteboard`; the code said `board`; the interface said "studio" -- and all three appeared within a few lines of each other, "Nothing for a board to record" sitting beside `Studio "X" saved.` Two names for one thing is what this whole change set has been removing. `studio` wins because it is what a reader sees and what the surface is. DOWN TO THE TABLE, which is the part that was asked for. `whiteboards` -> `studios`, `whiteboard_members` -> `studio_members`, `whiteboard_id` -> `studio_id`. store.Whiteboard is store.Studio, WhiteboardMember is StudioMember, and the five bindings are SaveStudio, ListStudios, GetStudio, RenameStudio, DeleteStudio. internal/store/whiteboards.go, app_whiteboards.go, lib/whiteboards.ts and components/whiteboard/ are all renamed with git mv. THE RENAME RUNS FIRST, BEFORE ANY CREATE TABLE, and that ordering is the whole of its safety. Every schema block here is CREATE TABLE IF NOT EXISTS, so running them against a file still holding `whiteboards` makes an empty `studios` beside the populated one and every reader finds the empty one -- no error, no missing table, just studios that are gone. TestABoardSavedAsAWhiteboardOpensAsAStudio builds the old file and asserts the rows, the arrangement and the member's own name come through; moving the renames after the CREATE makes it fail. A FILE CAN HOLD BOTH NAMES, and it is not hypothetical: it is what this author's database was in when the rename landed, because a dev build compiled with the new schema and without the rename. Refusing there means a store that will not open with the work intact on disk behind it, which is worse than either name. An EMPTY destination is dropped and the rename proceeds -- a CREATE made it and it holds nothing. A POPULATED one is refused, naming the pair: which of two full tables is the work is not something this can answer. Both are tested. The renames are ungated, like the drops beside them and for the reason that commit gives: a version number can be raised without the work, and a file that slips through such a window is one no later gate reopens. On a database already renamed this is three schema queries. The old index names are dropped rather than renamed. No index is addressed by name anywhere in this package, so renaming them would be churn with no reader -- but leaving them would give a file two indexes over one column. USER-FACING TEXT loses "board" too: "Remove from the studio", "Add to the studio", "Nothing for a studio to record". The Board* component names stay -- BoardSurface, boardScene, boardMemory -- because those name a surface a reader of the code follows, not a second word for the subject. CHANGELOG.md is left alone. Those scopes are what the commits said at the time, and rewriting them would falsify the record. Verified against the live database: a studio named "compara RJ MA" came through the rename with its member and its project. gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend tests pass, check:types clean.
It opened over Brazil at zoom 1.6 every time, however far the reader had travelled on it, while the work map beside it came back where it was left. The two show the same planet. The memory already existed and the globe was the one surface not wired to it: `initialView` and `handleViewChange` are threaded through MapScreen to the map already, and the comment on that pair says why there is one of them rather than two -- "a pan made on the energy screen [would be] lost on the way back to the map, or the reverse, depending on which one last committed". A globe with its own memory would be a third way to lose one. START_ZOOM and the centre over Brazil become the fallback rather than the opening: what a session with no memory gets. Reported on moveend, not on move. What the session remembers is where a gesture ENDED, and writing per frame of a drag would be several hundred writes for one of them. Through a ref, because the map is built once and its effect closes over the callback it had at mount; `initialView` is read once there too, as the work map reads its own, since a prop that moved the camera on every change would fight the reader's hand. tsc clean, 289 frontend tests pass.
…ures Right-click a plane in the viewport and it offers "Send to the map". The raster is drawn on the work map at its own extent, above the rest. WHY THIS BELONGS. The studio lifts rasters off their coordinates so two grounds hundreds of kilometres apart can be read side by side -- which is what it is for, and also what it costs: a plane here answers "how do these compare" and cannot answer "where on the ground is this". Sending it back is the second question, asked of the plane the reader is already pointing at, which is the argument that put every other entry in this menu. WHAT TRAVELS is the RasterLayer itself -- image, extent, opacity, its smoothing setting -- not a reference. The map has no account of the studio's arrangement, and the run a plane belongs to may not be the one the map is showing; "the third layer of area two" means nothing on that side. Handing the layer over resolved also means the class boundary drawn on the map is the one the reader was looking at, since `smooth` decides where a boundary IS. A TOGGLE, like solo beside it and for the reason that entry gives: the label says which way it goes, so sending and taking back are one row rather than a one-way action with its undo somewhere else. HELD IN App, NOT IN THE STUDIO, because the map outlives it. State kept by the surface that sent the raster would be unmounted by the very gesture of closing the studio to go and look at it. ONE AT A TIME. Sending a second replaces the first: this is "put that one over the ground", a question about one raster, and a stack of ad-hoc overlays with no ordering control is a picture no one asked for. TWO WAYS OUT, because one is a dead end. The plane menu is the shape of the gesture, but a raster left on the map with its only control inside a surface the reader has closed is a mark they cannot lift. Overlay Tools is titled for what is drawn over the map, so a row appears there naming it, with Take off. Only while something is sent: an empty row explaining a feature is a permanent cost paid for an occasional state. Leaving a project takes it, beside the retained runs and for the same reason -- a raster of the project being left, drawn over the next one's ground, is that contamination in its most visible form. tsc clean, 289 frontend tests pass, check:types clean.
…eloading Found in the window's console while chasing something else: MapSurface was throwing "Style is not done loading", the error boundary was catching it, and the surface was recreated from scratch -- a new map, a new style, and every overlay gone. It reads as rasters that simply do not appear. `ready` is what the effect gated on, and it does not promise a loaded style. It is set on the map's `load` event, which fires once; the style is touched after that, and while a replacement is in flight `addSource` throws. Thrown from an effect that has nothing to do with the basemap, it left the boundary as the first thing to notice. CAUGHT RATHER THAN PRE-CHECKED, and the difference is the whole of it. `isStyleLoaded()` is the obvious guard and it is the wrong one: it reports false while any SOURCE is still loading, and the hillshade fetches DEM tiles on every pan -- so gating on it drops the overlay for most of the time a reader is moving. That guard was written here first and turned an intermittent failure into a permanent one, which is how it was found out. Deferred rather than dropped. `styledata` is how the style says it has settled; the retry runs the effect again with the same `resolved`, where dropping would leave the map missing an overlay it had been handed. tsc clean, 289 frontend tests pass.
…d for TWO THINGS WERE WRONG WITH THE FIRST VERSION, and the first of them is why it read as a control that did nothing. IT SENT TO THE WRONG MAP. "Send to the map" was built against the work map on the home screen -- the one surface the reader is NOT on while they are in the studio. The layer was added correctly every time, the camera even flew to the ground it measures, and all of it happened behind the surface the press was made on. The map that was meant is the globe in this room, where the areas already are. That is where it draws now, under the area outlines so they stay readable over it, and the studio no longer closes itself to reveal a result that was never there. The plumbing for the wrong target comes out with it: the state leaves App, the props leave MapScreen and MapSurface, and the Overlay Tools row that existed to take a raster off the work map goes. It was held in App on the reasoning that "the map outlives the studio" -- true, and irrelevant: the globe is part of this surface, so a raster on it has nothing to outlive. IT TOOK ONE AT A TIME, on my argument that "a stack with no ordering control is a picture no one asked for". The ordering was there all along: RasterLayer.order says which raster reads over which -- a classification over surface water, confidence over the classification -- and it is the same order the work map stacks by. The globe sorts on it, so several read here the way the same set reads there. ALLOWING SEVERAL EXPOSED A REAL DEFECT IN THE KEY. This filed a sent raster under the layer's own id, with a comment claiming "the same raster reached from a second area is the same thing on the ground". That is false the moment there is more than one area: two runs over two fields both call their raster `solar:terrain`, so the second would have replaced the first in silence while the menu marked both as on. The key is the area-and-layer pair, and not a new one -- `sceneKey` is what the rest of this surface already files per-plane state under. The camera fits the UNION of what is drawn rather than the newest. Sending a second raster over another field and being taken to it alone would hide the first, which is the comparison being made; two over the same ground give the same box, so nothing moves. The entry names its destination: "Show on the globe" / "Take off the globe". tsc clean, vite build clean, 289 frontend tests pass, gofmt and go vet clean.
|
Closing: this work is in The stack had grown ten branches deep and
Two things landed after this branch and are worth knowing about together with it:
|
…aming it golangci-lint reported `(*Runner).mapbiomasPath is unused`, and it was right in a way that reached the screen. The function resolved a MapBiomas raster by file name under global/data/mapbiomas. Its only callers were the three embedded example areas, removed in 8ead8fa; it outlived them with no caller anywhere. RepoRoot beside it still described itself as "where the legacy MapBiomas rasters are looked up", and the environment screen told the reader that the repository root blocks "the local MapBiomas rasters for embedded areas". None of that was true any more. Nothing in Go reads a MapBiomas raster from the repository root: what is left under it is the checked-out virtualenv and the model directory the runner falls back to, so that is what the screen says now. A path report that names a consumer which does not exist is worse than no report, because it is read by someone trying to work out why a run failed. CI IS WHERE THIS WAS FOUND, and that is the point worth recording. The gate this repository documents is gofmt, go vet, golangci-lint and go test -race, and golangci-lint had not run once during the work that landed in main -- it is not installed on the machine, which was noted in three commit messages and in PR #69 as a known gap. The gap was not theoretical. It is runnable without installing anything: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 run ./... which is the version CI pins, and reports 0 issues here.
Stacked on #68. Review that one first; this branch's diff against
mainincludes it.Why this exists
A project reported fifty-eight runs from several different fields. Nothing was wrong with the query — nothing owned anything.
preferences.extras_json, with an id the browser minted. Nothing in Go had ever seen one, so there was not a singleWHERE aoi_id = ?in the package.project_idandaoi_idwith neither in charge. The project came fromactiveProjectId, sticky session state restored at every boot, so runs inherited whichever project happened to be open.That last one is the root of half the visible symptoms: the studio's live area reported the sentinel
"current", boards refused to save, and compositions were filed against no run.What changes
The chain is
project › area › run, enforced where it can be. Areas are rows in a table, scoped to a project. Deleting an area takes its runs and their rasters; deleting a project takes its areas and theirs. None of that is enforced by foreign keys — they are declared here and never turned on — so every cascade is written where the delete is, and each has a test.Drawing requires a project. There is nowhere to put an area without one, so the map refuses and says so rather than inventing somewhere. That refusal is the whole point: it is what stops a run inheriting a label that got stuck to the session.
No migration. A clean cut, with the copy taken first. There is no rule that turns the old shape into the new one without inventing which area a run was of, and a wrong answer there is worse than an empty database because it reads as work rather than as absence. A snapshot is written before the first
DELETE, and the account — users, sessions, preferences — survives.Every product returns its run id, which lets the board record what it is showing and a composition be filed against the run it was made over.
The hub shows the middle level, which is what makes the model visible at all.
One name for the studio, down to the table:
whiteboards→studios, and the user-facing "board" goes with it. Three names for one thing was the same class of problem this branch exists to remove. The rename runs before anyCREATE TABLE— every schema block isIF NOT EXISTS, so running them first would leave an emptystudiosbeside the populatedwhiteboardsand every reader would find the empty one. Tested against a file built in the old shape.Two defects found by running it
Migrations gated on a version number can be skipped forever.
schemaVersionwas raised in one commit and the drop it stood for written in the next; a build compiled in between recorded the new number with no work done, and the gate then closed over three columns that could never be dropped again. The drops and renames are ungated now and ask the schema instead — the argumentaddColumnsalready made in the other direction. There is a regression test that builds exactly that file.An overlay could take the map down.
addSourcethrows while the style is being replaced; from an effect unrelated to the basemap it reached the error boundary, which recreated the map surface and lost every overlay. Caught and retried onstyledatarather than pre-checked withisStyleLoaded(), which reports false while any source is loading and turns an intermittent failure into a permanent one.Studio
Reworked around a node canvas, with the globe carrying drawing, relief, search and credits — which is what let the AOI drawing modal and its second map go. The studio opens with nothing on it, because that is where work starts; it names the project it is filing work into, since it is the surface that produces the most and was the only one not saying; and a plane can be shown on the globe beside it, over the ground it measures.
Known and deliberate
activeProjectIdis still sticky session state. Requiring a project before drawing is what stops it becoming a label that sticks to the work again.golangci-lintis not installed on this machine, so that part of the gate has not run.gofmt,go vet, the Go suite under-race,tsc, 289 frontend tests andcheck:typespass.