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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions cmd/chartplotter/bake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 31 additions & 6 deletions internal/engine/baker/baker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down
20 changes: 15 additions & 5 deletions internal/engine/server/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down
26 changes: 15 additions & 11 deletions web/src/chartplotter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1625,32 +1625,36 @@ 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");
const prog = r.getElementById("db-prog");
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).
Expand Down
46 changes: 24 additions & 22 deletions web/src/chartplotter.view.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -536,10 +538,10 @@ export const CHROME = `
readout while a job runs. Driven by _updateHud + _setNotification. -->
<div id="databox" hidden>
<div id="db-prog" class="db-prog" hidden>
<div class="db-prog-head">
<span class="db-prog-spin"></span>
<span id="db-prog-label" class="db-prog-label"></span>
<span id="db-prog-pct" class="db-prog-pct"></span>
<div id="db-prog-title" class="db-prog-title"></div>
<div class="db-prog-status">
<span id="db-prog-action" class="db-prog-action"></span>
<span id="db-prog-count" class="db-prog-count"></span>
</div>
<div class="db-prog-track"><span id="db-prog-fill" class="db-prog-fill"></span></div>
</div>
Expand Down
15 changes: 8 additions & 7 deletions web/src/core/notification-center.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
// rather than something every plugin re-implements.
//
// const notify = new NotificationCenter({
// onProgress: (p) => shell._setNotification(p), // p = {label, sub, frac, error} | null
// onProgress: (p) => shell._setNotification(p), // p = {label, sub, detail, frac, error} | null
// onMessage: (m) => shell._toast(m), // m = {level, msg}
// });
// const t = notify.task("download:noaa-d5", { label: "Downloading Mid-Atlantic" });
// t.progress(0.4, "12 / 30 cells"); // frac 0..1, optional detail
// const t = notify.task("download:noaa-d5", { label: "NOAA · Mid-Atlantic" });
// t.progress(0.4, "Generating coastal…", "12 / 30 tiles"); // frac, action, count
// t.done(); // or t.fail(err)
// notify.warn("No charts are enabled");
//
Expand All @@ -30,7 +30,7 @@ export class NotificationCenter {
// Begin (or re-open) a task; returns a handle. The handle's methods are the
// only way to drive the progress surface — callers never format the payload.
task(id, { label = "" } = {}) {
const t = { id, label, frac: null, sub: "", error: null, done: false };
const t = { id, label, frac: null, sub: "", detail: "", error: null, done: false };
this._tasks.set(id, t);
this._lastFailed = false;
this._render(t);
Expand All @@ -40,15 +40,16 @@ export class NotificationCenter {
this._render(t);
};
return {
// frac: 0..1 or null (indeterminate); sub: optional one-line detail.
progress: (frac, sub) => update({ frac, sub: sub == null ? t.sub : sub }),
// frac: 0..1 or null (indeterminate); sub: the live action line; detail: the
// count-with-unit beside it. Pass null to leave either unchanged.
progress: (frac, sub, detail) => update({ frac, sub: sub == null ? t.sub : sub, detail: detail == null ? t.detail : detail }),
label: (l) => update({ label: l }),
done: () => { t.frac = 1; t.done = true; this._render(t); this._end(id, false); },
fail: (err) => { t.error = err ? String((err && err.message) || err) : "Failed"; t.done = true; this._render(t); this._end(id, true); },
};
}

_render(t) { this._onProgress({ label: t.label, sub: t.sub, frac: t.frac, error: t.error }); }
_render(t) { this._onProgress({ label: t.label, sub: t.sub, detail: t.detail, frac: t.frac, error: t.error }); }

_end(id, failed) {
this._tasks.delete(id);
Expand Down
Loading