diff --git a/cmd/chartplotter/bake.go b/cmd/chartplotter/bake.go index 7a79b6d..31270ac 100644 --- a/cmd/chartplotter/bake.go +++ b/cmd/chartplotter/bake.go @@ -150,13 +150,17 @@ func (c bakeCmd) runBands(cells map[string]baker.CellData, aux map[string][]byte func(name string, err error) { fmt.Fprintf(os.Stderr, " skip %s: %v\n", name, err) }, - func(done, total int) { + func(stage string, done, total int, band string) { if total == 0 { return } if pct := done * 100 / total; pct != lastPct && pct%5 == 0 { lastPct = pct - fmt.Fprintf(os.Stderr, "\r tiles %d/%d (%d%%)", done, total, pct) + where := band + if where == "" { + where = "coverage" + } + fmt.Fprintf(os.Stderr, "\r %-9s %-8s %d/%d (%d%%) ", where, stage, done, total, pct) } }, func(slug string, pb *pmtiles.Builder) error { diff --git a/internal/engine/baker/baker.go b/internal/engine/baker/baker.go index 6bd7c17..dda44f3 100644 --- a/internal/engine/baker/baker.go +++ b/internal/engine/baker/baker.go @@ -143,7 +143,7 @@ func BuildBaker(cells map[string][]byte, onSkip func(name string, err error)) (* applyPortrayer(b) ok := addCellsParallel(b, names, func(name string) (*s57.Chart, error) { return ParseCellBytes(name, cells[name]) - }, onSkip) + }, onSkip, nil) return b, ok, nil } @@ -210,13 +210,20 @@ func parseInOrder[T any]( // the dominant bake cost and are independent per cell, so they run concurrently; // the stateful route/merge stays single-threaded and ordered, so the archive is // byte-for-byte identical to the serial path. Returns the routed cell names. -func addCellsParallel(b *bake.Baker, names []string, parse func(name string) (*s57.Chart, error), onSkip func(name string, err error)) []string { +// onCell, if non-nil, is called once per cell as it is routed (done, total) so the +// caller can report parse+portray progress — the dominant per-band cost, which is +// otherwise an invisible pause before any tile is emitted. +func addCellsParallel(b *bake.Baker, names []string, parse func(name string) (*s57.Chart, error), onSkip func(name string, err error), onCell func(done, total int)) []string { ok := make([]string, 0, len(names)) + total := len(names) parseInOrder(names, parse, func(_ string, chart *s57.Chart) bake.CellPortrayal { return b.PortrayCell(chart) }, func(name string, chart *s57.Chart, pc bake.CellPortrayal) { b.AddCellPortrayed(chart, pc) ok = append(ok, name) + if onCell != nil { + onCell(len(ok), total) + } }, onSkip) return ok } @@ -285,7 +292,7 @@ func BuildBakerWithUpdates(cells map[string]CellData, overzoom bool, onSkip func ok := addCellsParallel(b, names, func(name string) (*s57.Chart, error) { cd := cells[name] return ParseCellWithUpdates(name, cd.Base, cd.Updates) - }, onSkip) + }, onSkip, nil) return b, ok, nil } @@ -377,7 +384,12 @@ func emitTiles(coords []tile.TileCoord, pb *pmtiles.Builder, progress func(done, // routing, so the overhead is small relative to the memory saved. emit(slug, // builder) is called per band that produced tiles; returns the cell-union bounds // and the number of cells parsed. -func BakeToPMTilesBandsStreaming(cells map[string]CellData, maxZoom uint32, onSkip func(name string, err error), progress func(done, total int), emit func(slug string, pb *pmtiles.Builder) error) (geo.BoundingBox, int, error) { +// +// progress(stage, done, total, band) reports both visible stages so neither pause +// is a dead bar: stage "prepare" while a band's cells are parsed + portrayed (the +// long gap before any tile emits; band "" during the pass-1 coverage scan), then +// stage "tiles" while that band's tiles are emitted. +func BakeToPMTilesBandsStreaming(cells map[string]CellData, maxZoom uint32, onSkip func(name string, err error), progress func(stage string, done, total int, band string), emit func(slug string, pb *pmtiles.Builder) error) (geo.BoundingBox, int, error) { b := bake.New() applyPortrayer(b) if maxZoom > 0 { @@ -410,6 +422,9 @@ func BakeToPMTilesBandsStreaming(cells map[string]CellData, maxZoom uint32, onSk band := b.AddCellCoverage(chart) byBand[band.ZoomRange().Max] = append(byBand[band.ZoomRange().Max], name) parsed++ + if progress != nil { + progress("prepare", parsed, len(names), "") // coverage scan; no band yet + } }, onSkip) b.SetSkipCoverage(true) // covMeta is now global; don't re-derive it per band @@ -423,14 +438,24 @@ func BakeToPMTilesBandsStreaming(cells map[string]CellData, maxZoom uint32, onSk // Parse + portray this band's cells in parallel; route them serially in // order (deterministic, same as the per-cell loop). Coverage is skipped // (SetSkipCoverage above), so re-parsing is just for this band's geometry. - addCellsParallel(b, bandCells, parse, onSkip) + // This is the long pre-tile pause, so report it ("prepare") per routed cell. + var prep func(done, total int) + if progress != nil { + progress("prepare", 0, len(bandCells), bd.Slug) // announce the band up front + prep = func(done, total int) { progress("prepare", done, total, bd.Slug) } + } + addCellsParallel(b, bandCells, parse, onSkip, prep) coords := b.TileCoordsBand(MVTExtent, bd.Min, bd.Max) if len(coords) == 0 { continue } b.BuildEmitIndexBand(MVTExtent, MVTBuffer, bd.Max) pb := pmtiles.New() - emitTiles(coords, pb, progress, func(c tile.TileCoord, ts *bake.TileScratch) []byte { + var tileProg func(done, total int) + if progress != nil { + tileProg = func(done, total int) { progress("tiles", done, total, bd.Slug) } + } + emitTiles(coords, pb, tileProg, func(c tile.TileCoord, ts *bake.TileScratch) []byte { return b.EmitTileBandInto(c, MVTExtent, MVTBuffer, ts, bd.Max) }) b.ClearEmitIndex() diff --git a/internal/engine/server/import.go b/internal/engine/server/import.go index 8b8d0d2..54d6925 100644 --- a/internal/engine/server/import.go +++ b/internal/engine/server/import.go @@ -37,6 +37,7 @@ type importJob struct { Set string `json:"set"` State string `json:"state"` // "running" | "done" | "error" Phase string `json:"phase"` // "download" | "extract" | "bake" + Band string `json:"band"` // usage band being baked (e.g. "coastal"); "" outside the bake phase Note string `json:"note"` // human-readable current step (e.g. "downloading US5MD1MC") Done int `json:"done"` // phase units done (bytes/cells downloaded, then tiles emitted) Total int `json:"total"` // phase total (0 until known) @@ -496,7 +497,9 @@ func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellD } _ = overzoom // the per-band streaming bake has no all-bands-to-z0 overzoom mode s.imports.update(jobID, func(j *importJob) { - j.Phase, j.Unit, j.Note, j.Done, j.Total = "bake", "tiles", fmt.Sprintf("Baking %d cell(s)", len(cells)), 0, 0 + // Open on the "prepare" stage (unit "cells"): the bake starts by parsing + // cells for coverage, well before the first tile emits. + j.Phase, j.Unit, j.Band, j.Note, j.Done, j.Total = "bake", "cells", "", fmt.Sprintf("Baking %d cell(s)", len(cells)), 0, 0 }) // Drop any STALE merged archive named exactly `set` from a prior (pre-per-band) @@ -509,8 +512,15 @@ func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellD bands, tiles, first := 0, 0, true _, nCells, err := baker.BakeToPMTilesBandsStreaming(cells, 0, func(name string, e error) { log.Printf("import %s: skip %s: %v", jobID, name, e) }, - func(done, total int) { - s.imports.update(jobID, func(j *importJob) { j.Done, j.Total = done, total }) + func(stage string, done, total int, band string) { + // "prepare" = parsing + portraying a band's cells (the gap before any + // tile emits); "tiles" = emitting that band's tiles. The unit lets the + // client name the stage (Preparing … charts vs Generating … tiles). + unit := "tiles" + if stage == "prepare" { + unit = "cells" + } + s.imports.update(jobID, func(j *importJob) { j.Done, j.Total, j.Band, j.Unit = done, total, band, unit }) }, func(slug string, pb *pmtiles.Builder) error { bandSet := set + "-" + slug @@ -633,8 +643,8 @@ func (j importJob) statusJSON() string { pct = j.Done * 100 / j.Total } return fmt.Sprintf( - `{"ok":true,"id":%q,"set":%q,"state":%q,"phase":%q,"note":%q,"done":%d,"total":%d,"unit":%q,"percent":%d,"cells":%d,"error":%q}`, - j.ID, j.Set, j.State, j.Phase, j.Note, j.Done, j.Total, j.Unit, pct, j.Cells, j.Err) + `{"ok":true,"id":%q,"set":%q,"state":%q,"phase":%q,"band":%q,"note":%q,"done":%d,"total":%d,"unit":%q,"percent":%d,"cells":%d,"error":%q}`, + j.ID, j.Set, j.State, j.Phase, j.Band, j.Note, j.Done, j.Total, j.Unit, pct, j.Cells, j.Err) } // importStatus returns a job's state as JSON (one-shot poll). diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index e7fb5a4..46ba101 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -1625,10 +1625,12 @@ export class ChartPlotter extends HTMLElement { // Job progress (download / import / bake) lives in a row ABOVE the live nav // readout inside the bottom status card — one box, no separate pill or pop-out. - // `p` carries { label, pill, sub, frac, error }; null clears the row. The label - // and detail are packed onto one line so all the context (region · cell · count - // · size) is visible at a glance; the bar shows the fraction (indeterminate when - // unknown). Spacing is handled by the card's flex gap + the divider rule. + // `p` carries { label, pill, sub, detail, frac, error }; null clears the row. + // Three stacked pieces: the region TITLE (label) on top; beneath it the live + // ACTION (sub, incl. the band being baked) on the left with the COUNT (detail, + // unit spelled out) pinned right. There's no percentage — the bar alone carries + // the proportion, so the count is the only moving number (a single slow sweep + // when the fraction is unknown — no spinner). Spacing is the card's flex gap. _setNotification(p) { const r = this.shadowRoot; const box = r.getElementById("databox"); @@ -1636,21 +1638,23 @@ export class ChartPlotter extends HTMLElement { if (!box || !prog) return; if (!p) { prog.hidden = true; - prog.classList.remove("busy", "error"); + prog.classList.remove("error"); return; } box.hidden = false; // a job can finish before the map readout first paints const done = p.frac === 1 || !!p.error; prog.hidden = false; - prog.classList.toggle("busy", !done); // spinner while working prog.classList.toggle("error", !!p.error); - const detail = p.sub && p.sub.trim() ? p.sub.trim() : ""; - const label = p.label || p.pill || ""; - r.getElementById("db-prog-label").textContent = detail ? `${label} · ${detail}` : label; - r.getElementById("db-prog-pct").textContent = p.frac != null ? `${Math.round(p.frac * 100)}%` : ""; + // On error the reason takes the action line and the count is cleared. + const title = p.label || p.pill || ""; + const action = p.error ? String(p.error) : (p.sub || ""); + const count = p.error ? "" : (p.detail || ""); + r.getElementById("db-prog-title").textContent = title; + r.getElementById("db-prog-action").textContent = action; + r.getElementById("db-prog-count").textContent = count; const fill = r.getElementById("db-prog-fill"); fill.style.width = p.frac != null ? `${Math.round(p.frac * 100)}%` : "100%"; - fill.classList.toggle("indet", p.frac == null && !done); // sweeping bar when no fraction + fill.classList.toggle("indet", p.frac == null && !done); // slow sweep when no fraction } // Frame to the union bounds of the installed region archives (from the manifest). diff --git a/web/src/chartplotter.view.mjs b/web/src/chartplotter.view.mjs index 74ea6d6..b13fe38 100644 --- a/web/src/chartplotter.view.mjs +++ b/web/src/chartplotter.view.mjs @@ -384,26 +384,28 @@ export const STYLE = ` /* Job-progress row inside the bottom status card — sits ABOVE the nav readout, separated by a hairline divider. One width with the card so the label can run full-width; the percentage is pinned right. */ - .db-prog { width:100%; box-sizing:border-box; display:flex; flex-direction:column; gap:6px; - padding-bottom:7px; margin-bottom:1px; border-bottom:1px solid var(--ui-border); } + .db-prog { width:100%; box-sizing:border-box; display:flex; flex-direction:column; gap:7px; + padding-bottom:9px; margin-bottom:2px; border-bottom:1px solid var(--ui-border); } .db-prog[hidden] { display:none; } - .db-prog-head { display:flex; align-items:center; gap:8px; font:600 12px/1.2 system-ui,sans-serif; color:var(--ui-text); } - .db-prog-label { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .db-prog-pct { flex:none; font-weight:700; color:var(--ui-accent); font-variant-numeric:tabular-nums; } - .db-prog.error .db-prog-pct { color:#c0392b; } - .db-prog.error .db-prog-label { color:#c0392b; } - /* Spinner shows only while actively working (not on the done/error frame). */ - .db-prog-spin { width:13px; height:13px; flex:none; border-radius:50%; display:none; - border:2px solid color-mix(in srgb, var(--ui-accent) 30%, transparent); border-top-color:var(--ui-accent); - animation:dlspin .8s linear infinite; } - .db-prog.busy .db-prog-spin { display:inline-block; } - .db-prog-track { position:relative; width:100%; height:5px; border-radius:3px; overflow:hidden; background:var(--ui-surface-2); } - .db-prog-fill { position:absolute; left:0; top:0; bottom:0; width:0; border-radius:3px; background:var(--ui-accent); transition:width .25s ease; } + /* Title (region) on top — the stable headline; the eye isn't re-reading it. */ + .db-prog-title { font:600 12.5px/1.25 system-ui,sans-serif; color:var(--ui-text); + overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .db-prog-title:empty { display:none; } + /* Status row beneath it: the live action (left) + the count-with-unit (right, + the ONLY moving number — the bar carries the proportion, no percentage). */ + .db-prog-status { display:flex; align-items:baseline; gap:10px; + font:500 11.5px/1.3 system-ui,sans-serif; color:var(--ui-text-dim); } + .db-prog-action { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .db-prog-count { flex:none; font-variant-numeric:tabular-nums; } + .db-prog-status:empty { display:none; } + .db-prog.error .db-prog-title, .db-prog.error .db-prog-action { color:#c0392b; } + .db-prog-track { position:relative; width:100%; height:6px; border-radius:3px; overflow:hidden; background:var(--ui-surface-2); } + .db-prog-fill { position:absolute; left:0; top:0; bottom:0; width:0; border-radius:3px; background:var(--ui-accent); transition:width .3s ease; } .db-prog.error .db-prog-fill { background:#c0392b; } - /* Indeterminate (no known fraction): a sweeping segment instead of a fill. */ - .db-prog-fill.indet { width:35% !important; animation:db-sweep 1.1s ease-in-out infinite; } - @keyframes db-sweep { 0% { left:-35%; } 100% { left:100%; } } - @media (prefers-reduced-motion: reduce) { .db-prog-spin { animation-duration:2s; } .db-prog-fill.indet { animation:none; left:0; width:100% !important; } } + /* Indeterminate (no known fraction): one slow, calm sweep — no spinner. */ + .db-prog-fill.indet { width:30% !important; animation:db-sweep 1.9s ease-in-out infinite; } + @keyframes db-sweep { 0% { left:-30%; } 100% { left:100%; } } + @media (prefers-reduced-motion: reduce) { .db-prog-fill.indet { animation:none; left:0; width:100% !important; } } /* NotificationCenter banners: a bottom-stacked toast list (non-task messages). */ #toasts { position:absolute; left:50%; bottom:calc(var(--botbar-h) + 14px); transform:translateX(-50%); z-index:9; display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none; } .toast { pointer-events:auto; max-width:80vw; padding:9px 14px; border-radius:8px; font:600 12.5px/1.3 system-ui,sans-serif; color:var(--ui-text); background:var(--ui-surface); border:1px solid var(--ui-border-2); box-shadow:0 4px 16px rgba(0,0,0,.28); opacity:1; transition:opacity .3s ease, transform .3s ease; } @@ -536,10 +538,10 @@ export const CHROME = ` readout while a job runs. Driven by _updateHud + _setNotification. -->