@bicharts/chart-host 0.6.81 → 0.6.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -278,6 +278,79 @@ made for: every word shares a comment with many others, so the default lit most
278
278
  click. The cross-filter sent out is the same under both rules. `onSelectionPaint` reports how each
279
279
  paint of a declaring chart decided, including when no mark matched and the default was used.
280
280
 
281
+ ## Remembering what the reader did
282
+
283
+ A chart keeps its resting state (a sort, an expanded row, a 3D camera angle) in a small bag. It
284
+ reads the bag from `options.uiState` and writes it through `options.setUiState`, and generated code
285
+ never learns where the bag goes. Where it goes is your host's choice, and so is which keys may
286
+ outlive what: a redraw, a close and reopen, or a new version of the chart.
287
+
288
+ `createChartHost` still takes one `viewState` provider, as before. When your host needs more than
289
+ one store, describe it with a `ViewStateService`:
290
+
291
+ ```ts
292
+ import { sessionViewStateProvider, type ViewStateService } from "@bicharts/chart-host";
293
+
294
+ const viewState: ViewStateService = {
295
+ viewing: sessionViewStateProvider(el), // survives a redraw; a page switch starts clean
296
+ durable: workbookStore, // optional: survives close and reopen
297
+ policy: { // the keys your host scopes, by name
298
+ camera: { lifetime: "durable", dropOnNewVersion: true, dropOnFreshViewing: false, durableWhen: "rememberView" },
299
+ userStopped: { lifetime: "durable", dropOnNewVersion: false, dropOnFreshViewing: true },
300
+ },
301
+ versionKey: "cameraVersion", // optional: how a version change is noticed
302
+ };
303
+ ```
304
+
305
+ **What you provide.** `viewing` and `policy` are required (`policy: {}` is fine). Everything else is
306
+ optional, and leaving it out says something about your host:
307
+
308
+ - no `durable` store: the host can't remember across a close (a static preview, a server-side
309
+ render). `viewStateIsDurable(service)` is the one definition of "remembers the view", so it can't
310
+ be claimed without a store to back it. `noopViewStateService()` says "nothing is stored" out loud.
311
+ - no `chartKeys`: keys your policy doesn't name belong to the chart. They're kept, durably, through a
312
+ new version and a fresh viewing, and the chart validates what it reads. Set `chartKeys` to give
313
+ them another rule. A host that forgets everything when its chart changes sets `dropOnNewVersion`.
314
+ - no `versionKey`: you apply the version rule yourself, where you learn the chart changed.
315
+ - no `durableMaxChars`: the durable store accepts any size.
316
+
317
+ **What a key can be.** `lifetime: "viewing"` keeps a key out of the durable store; `"durable"` puts it
318
+ in both. `dropOnNewVersion` drops it when the chart on screen is another version (a camera is aimed
319
+ at one chart's axes). `dropOnFreshViewing` drops it when the bag is read at the start of a viewing
320
+ instead of in the one that wrote it. `durableWhen` names a preference that must be on for the key to
321
+ reach the durable store; the viewing store keeps it either way.
322
+
323
+ **The rules are plain functions** in `@bicharts/chart-host/view-state`, with no DOM, timers or
324
+ storage of their own:
325
+
326
+ ```ts
327
+ import { resolveViewState, commitViewState } from "@bicharts/chart-host/view-state";
328
+
329
+ const { bag, dropped } = resolveViewState(viewState, { codeVersion, freshViewing }); // before a render
330
+ commitViewState(next, viewState, prefs); // when the chart writes
331
+ ```
332
+
333
+ `resolveViewState` builds the bag the chart is handed: the viewing store, else the durable one, with
334
+ the policy applied. `commitViewState` decides what a write reaches: the viewing store gets all of
335
+ it, the durable store gets what the policy allows. Both report what they did and neither throws.
336
+ `viewStateAfterSwap(service, bag)` applies the version rule for a host that sees the swap itself.
337
+
338
+ **Check your stores** with the conformance suite in the testing subpath. It confirms `load` never
339
+ throws and reads `{}` when empty, that `save` replaces, that nested values round-trip, that the
340
+ viewing store survives your host being re-created on the same element, and that the durable store
341
+ holds what it's given across a reopen:
342
+
343
+ ```ts
344
+ import { assertViewStateConformance } from "@bicharts/chart-host/testing";
345
+
346
+ const report = await assertViewStateConformance(service, {
347
+ recreate: () => buildService(sameElement), // the host drawing again
348
+ reopen: () => buildService(newElement), // a new viewing over the same saved file
349
+ settle: () => flushPendingWrites(), // only if your durable save is debounced
350
+ });
351
+ expect(report.skipped).toEqual([]); // nothing went unchecked
352
+ ```
353
+
281
354
  ## Architecture — three layers, and why it matters if you contribute
282
355
 
283
356
  Read this before adding code. The package has **three** concerns, and they are independent.
package/dist/card.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /*! @bicharts/chart-host — Apache-2.0. Bundled reference data: GeoNames (https://www.geonames.org/) CC BY 4.0; Natural Earth and US Census/TIGER (public domain). Full text: NOTICE in this package. */
2
2
  import {
3
3
  computeSelectionCard
4
- } from "./chunk-7HPVGRQ2.mjs";
5
- import "./chunk-ZRSEKV4I.mjs";
4
+ } from "./chunk-4KCSAHL5.mjs";
5
+ import "./chunk-2XWE6YFO.mjs";
6
6
  import "./chunk-GHODWOAL.mjs";
7
7
 
8
8
  // src/selectionCardChrome.ts
@@ -1140,7 +1140,7 @@ function F3(e) {
1140
1140
  function N2(e) {
1141
1141
  let t = F3(e);
1142
1142
  if (t.length === 0 || ne2(e) < 95) return false;
1143
- let n = t.filter((l2) => te(l2) && !t3(l2)).length, o2 = t.filter((l2) => !te(l2) && !!t3(l2)).length;
1143
+ let n = t.filter((u4) => te(u4) && !t3(u4)).length, o2 = t.filter((u4) => !te(u4) && !!t3(u4)).length;
1144
1144
  return n >= 1 && n >= o2;
1145
1145
  }
1146
1146
  function ce(e, t) {
@@ -1185,7 +1185,7 @@ function Yn2(e, t) {
1185
1185
  let n = F3(e);
1186
1186
  if (n.length === 0) return 0;
1187
1187
  let o2 = 0;
1188
- for (let l2 of n) j3(l2, t) && o2++;
1188
+ for (let u4 of n) j3(u4, t) && o2++;
1189
1189
  return Math.round(o2 / n.length * 1e3) / 10;
1190
1190
  }
1191
1191
  function ee(e, t) {
@@ -1198,13 +1198,13 @@ function Qn(e, t) {
1198
1198
  for (let o2 of oe(e)) t(o2) && n++;
1199
1199
  return n;
1200
1200
  }
1201
- function Xn(e, t, n, o2, l2) {
1202
- let u4 = [], a3 = [], i = {}, h4 = {}, C = new Set(e), y2 = (s2) => t.map((d2) => d2[s2]);
1201
+ function Xn(e, t, n, o2, u4) {
1202
+ let l2 = [], a3 = [], i = {}, h4 = {}, C = new Set(e), y2 = (s2) => t.map((d2) => d2[s2]);
1203
1203
  for (let s2 of ["city", "country"]) {
1204
1204
  let d2 = n?.[s2];
1205
1205
  d2 && C.has(d2) && (i[s2] = d2);
1206
1206
  }
1207
- let g5 = new Set(l2 ?? e);
1207
+ let g5 = new Set(u4 ?? e);
1208
1208
  for (let s2 of ["lat", "lon"]) {
1209
1209
  let d2 = n?.[s2];
1210
1210
  d2 && g5.has(d2) && (i[s2] = d2);
@@ -1230,13 +1230,13 @@ function Xn(e, t, n, o2, l2) {
1230
1230
  let K3 = ee(S2, (G4) => !!t3(G4)), Ne2 = ee(S2, (G4) => !!t3(G4) && !ce(G4, o2));
1231
1231
  K3 < Z2 && Ne2 < 1 || (!s2 || O4 > s2.pct) && (s2 = { name: d2, pct: O4 });
1232
1232
  }
1233
- s2 && (i.state = s2.name, p4.add(s2.name), u4.push(`state=${s2.name} (${s2.pct}% states/provinces)`));
1233
+ s2 && (i.state = s2.name, p4.add(s2.name), l2.push(`state=${s2.name} (${s2.pct}% states/provinces)`));
1234
1234
  }
1235
1235
  if (!i.zip) for (let s2 of b4) {
1236
1236
  if (p4.has(s2)) continue;
1237
1237
  let d2 = y2(s2), S2 = re2(d2);
1238
1238
  if (!(S2 < 95) && de(d2, s2) && !(Qn(d2, (O4) => r3(O4).length > 0) < Z2)) {
1239
- i.zip = s2, p4.add(s2), u4.push(`zip=${s2} (${S2}% ZIP codes)`);
1239
+ i.zip = s2, p4.add(s2), l2.push(`zip=${s2} (${S2}% ZIP codes)`);
1240
1240
  break;
1241
1241
  }
1242
1242
  }
@@ -1248,7 +1248,7 @@ function Xn(e, t, n, o2, l2) {
1248
1248
  let S2 = ne2(y2(d2));
1249
1249
  (!s2 || S2 > s2.pct) && (s2 = { name: d2, pct: S2 });
1250
1250
  }
1251
- s2 && (T2 && (a3.push(`country=${T2} (only ${ne2(y2(T2))}% of values are country identifiers; using ${s2.name} instead)`), h4.country = T2), i.country = s2.name, p4.add(s2.name), u4.push(`country=${s2.name}`));
1251
+ s2 && (T2 && (a3.push(`country=${T2} (only ${ne2(y2(T2))}% of values are country identifiers; using ${s2.name} instead)`), h4.country = T2), i.country = s2.name, p4.add(s2.name), l2.push(`country=${s2.name}`));
1252
1252
  }
1253
1253
  if (!i.city) {
1254
1254
  let s2 = null;
@@ -1257,13 +1257,13 @@ function Xn(e, t, n, o2, l2) {
1257
1257
  let S2 = y2(d2), O4 = Yn2(S2, o2);
1258
1258
  O4 < D3 || ee(S2, (K3) => j3(K3, o2)) < Z2 || (!s2 || O4 > s2.pct) && (s2 = { name: d2, pct: O4 });
1259
1259
  }
1260
- s2 && (i.city = s2.name, u4.push(`city=${s2.name} (${s2.pct}% known cities)`));
1260
+ s2 && (i.city = s2.name, l2.push(`city=${s2.name} (${s2.pct}% known cities)`));
1261
1261
  }
1262
1262
  let L4 = i.country ? y2(i.country) : [], R4 = !!i.country && N2(L4), A = R4 ? new Set(L4.map((s2) => k(s2 == null ? null : String(s2))).filter(Boolean)).size : 0, x3 = R4 && A >= 2, w4 = !!(i.lat && i.lon) && t.some((s2) => {
1263
1263
  let d2 = +String(s2[i.lat] ?? "").trim(), S2 = +String(s2[i.lon] ?? "").trim();
1264
1264
  return isFinite(d2) && isFinite(S2) && d2 >= -90 && d2 <= 90 && S2 >= -180 && S2 <= 180 && String(s2[i.lat] ?? "").trim() !== "" && String(s2[i.lon] ?? "").trim() !== "";
1265
1265
  });
1266
- return i.lat && i.lon && !w4 && a3.push(`lat/lon=${i.lat}/${i.lon} (no row holds a usable coordinate pair)`), i.country && !x3 && !(i.city || i.state || i.zip || w4) && a3.push(R4 ? `country=${i.country} (every row resolves to the same country - nothing to place apart)` : `country=${i.country} (values are not country identifiers)`), { bind: !!(i.city || i.state || i.zip || x3 || w4) ? i : null, backfilled: u4, refused: a3, ...Object.keys(h4).length ? { labelFallback: h4 } : {} };
1266
+ return i.lat && i.lon && !w4 && a3.push(`lat/lon=${i.lat}/${i.lon} (no row holds a usable coordinate pair)`), i.country && !x3 && !(i.city || i.state || i.zip || w4) && a3.push(R4 ? `country=${i.country} (every row resolves to the same country - nothing to place apart)` : `country=${i.country} (values are not country identifiers)`), { bind: !!(i.city || i.state || i.zip || x3 || w4) ? i : null, backfilled: l2, refused: a3, ...Object.keys(h4).length ? { labelFallback: h4 } : {} };
1267
1267
  }
1268
1268
  var r = (e, ...t) => ({ part: e, langs: Object.freeze(t) });
1269
1269
  var ie = Object.freeze({ jaar: r("year", "nl"), kwartaal: r("quarter", "nl"), maand: r("month", "nl"), dag: r("day", "nl", "sv", "da", "nb"), jahr: r("year", "de"), quartal: r("quarter", "de"), monat: r("month", "de"), tag: r("day", "de"), ann\u00E9e: r("year", "fr"), mois: r("month", "fr"), jour: r("day", "fr"), trimestre: r("quarter", "fr", "es", "pt", "it"), a\u00F1o: r("year", "es"), mes: r("month", "es"), d\u00EDa: r("day", "es"), ano: r("year", "pt"), m\u00EAs: r("month", "pt"), dia: r("day", "pt"), anno: r("year", "it"), mese: r("month", "it"), giorno: r("day", "it"), \u0433\u043E\u0434: r("year", "ru"), \u043C\u0435\u0441\u044F\u0446: r("month", "ru"), \u0440\u0456\u043A: r("year", "uk"), \u043C\u0456\u0441\u044F\u0446\u044C: r("month", "uk"), \u043A\u0432\u0430\u0440\u0442\u0430\u043B: r("quarter", "ru", "uk"), \u0434\u0435\u043D\u044C: r("day", "ru", "uk"), rok: r("year", "pl", "cs", "sk"), kwarta\u0142: r("quarter", "pl"), miesi\u0105c: r("month", "pl"), dzie\u0144: r("day", "pl"), \u010Dtvrtlet\u00ED: r("quarter", "cs"), m\u011Bs\u00EDc: r("month", "cs"), den: r("day", "cs"), \u0161tvr\u0165rok: r("quarter", "sk"), mesiac: r("month", "sk"), de\u0148: r("day", "sk"), \u00E5r: r("year", "sv", "da", "nb"), kvartal: r("quarter", "sv", "da", "nb"), m\u00E5nad: r("month", "sv"), m\u00E5ned: r("month", "da", "nb"), vuosi: r("year", "fi"), vuosinelj\u00E4nnes: r("quarter", "fi"), kuukausi: r("month", "fi"), p\u00E4iv\u00E4: r("day", "fi"), \u00E9v: r("year", "hu"), negyed\u00E9v: r("quarter", "hu"), h\u00F3nap: r("month", "hu"), nap: r("day", "hu"), y\u0131l: r("year", "tr"), \u00E7eyrek: r("quarter", "tr"), ay: r("month", "tr"), g\u00FCn: r("day", "tr"), \u03AD\u03C4\u03BF\u03C2: r("year", "el"), \u03C4\u03C1\u03AF\u03BC\u03B7\u03BD\u03BF: r("quarter", "el"), \u03BC\u03AE\u03BD\u03B1\u03C2: r("month", "el"), \u03B7\u03BC\u03AD\u03C1\u03B1: r("day", "el"), an: r("year", "ro"), trimestru: r("quarter", "ro"), lun\u0103: r("month", "ro"), zi: r("day", "ro"), godina: r("year", "hr"), tromjese\u010Dje: r("quarter", "hr"), mjesec: r("month", "hr"), dan: r("day", "hr"), \u5E74: r("year", "ja", "zh"), \u56DB\u534A\u671F: r("quarter", "ja"), \u5B63\u5EA6: r("quarter", "zh"), \u6708: r("month", "ja", "zh"), \u65E5: r("day", "ja", "zh"), \uC5F0\uB3C4: r("year", "ko"), \uBD84\uAE30: r("quarter", "ko"), \uC6D4: r("month", "ko"), \uC77C: r("day", "ko"), tahun: r("year", "id"), kuartal: r("quarter", "id"), triwulan: r("quarter", "id"), bulan: r("month", "id"), hari: r("day", "id"), n\u0103m: r("year", "vi"), qu\u00FD: r("quarter", "vi"), th\u00E1ng: r("month", "vi"), ng\u00E0y: r("day", "vi"), \u0E1B\u0E35: r("year", "th"), \u0E44\u0E15\u0E23\u0E21\u0E32\u0E2A: r("quarter", "th"), \u0E40\u0E14\u0E37\u0E2D\u0E19: r("month", "th"), \u0E27\u0E31\u0E19: r("day", "th"), \u0627\u0644\u0633\u0646\u0629: r("year", "ar"), \u0633\u0646\u0629: r("year", "ar"), \u0627\u0644\u0639\u0627\u0645: r("year", "ar"), \u0639\u0627\u0645: r("year", "ar"), \u0627\u0644\u0631\u0628\u0639: r("quarter", "ar"), \u0631\u0628\u0639: r("quarter", "ar"), "\u0631\u0628\u0639 \u0627\u0644\u0633\u0646\u0629": r("quarter", "ar"), \u0627\u0644\u0634\u0647\u0631: r("month", "ar"), \u0634\u0647\u0631: r("month", "ar"), \u0627\u0644\u064A\u0648\u0645: r("day", "ar"), \u064A\u0648\u0645: r("day", "ar"), \u05E9\u05E0\u05D4: r("year", "he"), \u05E8\u05D1\u05E2\u05D5\u05DF: r("quarter", "he"), \u05D7\u05D5\u05D3\u05E9: r("month", "he"), \u05D9\u05D5\u05DD: r("day", "he"), \u0935\u0930\u094D\u0937: r("year", "hi"), \u0938\u093E\u0932: r("year", "hi"), \u0924\u093F\u092E\u093E\u0939\u0940: r("quarter", "hi"), \u092E\u0939\u0940\u0928\u093E: r("month", "hi"), \u092E\u093E\u0939: r("month", "hi"), \u0926\u093F\u0928: r("day", "hi"), \u0633\u0627\u0644: r("year", "fa"), \u0645\u0627\u0647: r("month", "fa"), \u0631\u0648\u0632: r("day", "fa") });
@@ -6,7 +6,7 @@ import {
6
6
  ht,
7
7
  kr,
8
8
  pt
9
- } from "./chunk-ZRSEKV4I.mjs";
9
+ } from "./chunk-2XWE6YFO.mjs";
10
10
 
11
11
  // src/sourceDateFormat.ts
12
12
  function formatSourceDate(raw, opts = {}) {
@@ -5,7 +5,7 @@ import {
5
5
  X,
6
6
  Xn,
7
7
  Yn
8
- } from "./chunk-ZRSEKV4I.mjs";
8
+ } from "./chunk-2XWE6YFO.mjs";
9
9
  import {
10
10
  censusHitBands,
11
11
  createMarkResolver,
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  formatSourceDateFor,
6
6
  isExcelDateFormat,
7
7
  normaliseAggregation
8
- } from "./chunk-7HPVGRQ2.mjs";
8
+ } from "./chunk-4KCSAHL5.mjs";
9
9
  import {
10
10
  ANNOTATION_CLASS,
11
11
  ANNOTATION_LAYER_CLASS,
@@ -111,12 +111,12 @@ import {
111
111
  toSourceRows,
112
112
  unpinScrolledAxis,
113
113
  valuePlacementFlag
114
- } from "./chunk-MP5S6MVW.mjs";
114
+ } from "./chunk-CJNONH6V.mjs";
115
115
  import {
116
116
  _3,
117
117
  l,
118
118
  o
119
- } from "./chunk-ZRSEKV4I.mjs";
119
+ } from "./chunk-2XWE6YFO.mjs";
120
120
  import {
121
121
  MIN_HIT_BAND_PX,
122
122
  SELECTION_GLYPH_CLASS,
@@ -2073,6 +2073,11 @@ function markerArmedBlind(m2) {
2073
2073
  if (!m2) return false;
2074
2074
  return (m2.h ?? m2.v - 1) <= 0;
2075
2075
  }
2076
+ function markerArmedAtMs(m2) {
2077
+ if (!m2) return void 0;
2078
+ const t2 = Math.floor(m2.t);
2079
+ return Number.isFinite(t2) && t2 > 0 ? t2 : void 0;
2080
+ }
2076
2081
  function isBlindMarker(m2) {
2077
2082
  if (!m2) return false;
2078
2083
  return typeof m2.c !== "string" || m2.c.trim() === "";
@@ -2131,10 +2136,11 @@ function recoveryFinalOutcome(r2) {
2131
2136
  if (!r2) return null;
2132
2137
  const o3 = String(r2.pollOutcome ?? "").trim().toLowerCase();
2133
2138
  if (r2.isGenerationCancelled === true || r2.isPollFinal === true && o3 === "cancelled") return "cancelled";
2134
- if (r2.isPollFinal === true) return o3 === "not-received" ? "not-received" : "failed";
2139
+ if (r2.isPollFinal === true) return o3 === "not-received" ? "not-received" : o3 === "lost-in-restart" ? "lost-in-restart" : "failed";
2135
2140
  if (r2.isPollFinal == null && r2.isVersionNotFound === true) {
2136
2141
  const c2 = String(r2.errorCode ?? "").trim().toUpperCase();
2137
2142
  if (c2 === "GENERATION_NOT_RECEIVED") return "not-received";
2143
+ if (c2 === "GENERATION_LOST_IN_RESTART") return "lost-in-restart";
2138
2144
  if (c2 === "GENERATION_FAILED") return "failed";
2139
2145
  if (c2 === "GENERATION_CANCELLED") return "cancelled";
2140
2146
  }
@@ -2152,11 +2158,12 @@ function recoveryLooksUnreachable(transportFailuresInARow, everAnswered) {
2152
2158
  }
2153
2159
  var GENERATION_NOT_RECEIVED_MESSAGE = "The charting service has no record of that request, so nothing was generated and nothing was charged. Generate again.";
2154
2160
  var GENERATION_FAILED_MESSAGE = "That chart didn't finish on the server, so there's nothing to show. Generate again.";
2161
+ var GENERATION_LOST_IN_RESTART_MESSAGE = "The charting service restarted while your chart was being built, so nothing was delivered. Click Generate to build it again.";
2155
2162
  var RECOVERY_UNREACHABLE_MESSAGE = "We couldn't reach the charting service to check on your chart - it may be offline, or blocked on this network. Try again in a moment.";
2156
2163
  function recoveryFinalMessage(o3, serverMessage) {
2157
2164
  const s2 = String(serverMessage ?? "").trim();
2158
2165
  if (s2 !== "") return s2;
2159
- return o3 === "cancelled" ? GENERATION_CANCELLED_MESSAGE : o3 === "not-received" ? GENERATION_NOT_RECEIVED_MESSAGE : GENERATION_FAILED_MESSAGE;
2166
+ return o3 === "cancelled" ? GENERATION_CANCELLED_MESSAGE : o3 === "not-received" ? GENERATION_NOT_RECEIVED_MESSAGE : o3 === "lost-in-restart" ? GENERATION_LOST_IN_RESTART_MESSAGE : GENERATION_FAILED_MESSAGE;
2160
2167
  }
2161
2168
 
2162
2169
  // src/chartPick.ts
@@ -2339,6 +2346,7 @@ export {
2339
2346
  FLIP_MODE_DEFAULT,
2340
2347
  GENERATION_CANCELLED_MESSAGE,
2341
2348
  GENERATION_FAILED_MESSAGE,
2349
+ GENERATION_LOST_IN_RESTART_MESSAGE,
2342
2350
  GENERATION_NOT_RECEIVED_MESSAGE,
2343
2351
  GEO_POINT_PRECISIONS,
2344
2352
  GLYPH_SAMPLE_N,
@@ -2498,6 +2506,7 @@ export {
2498
2506
  loadGeo,
2499
2507
  markDefaultModel,
2500
2508
  markKeyOf,
2509
+ markerArmedAtMs,
2501
2510
  markerArmedBlind,
2502
2511
  measureContainerBoxes,
2503
2512
  minContrastOver,
package/dist/react.mjs CHANGED
@@ -22,8 +22,8 @@ import {
22
22
  loadGeo,
23
23
  syncMemberSelection,
24
24
  toSourceRows
25
- } from "./chunk-MP5S6MVW.mjs";
26
- import "./chunk-ZRSEKV4I.mjs";
25
+ } from "./chunk-CJNONH6V.mjs";
26
+ import "./chunk-2XWE6YFO.mjs";
27
27
  import "./chunk-MGAO2DR7.mjs";
28
28
  import "./chunk-A2GMXZP7.mjs";
29
29
  import "./chunk-7JWL6LYE.mjs";
package/dist/testing.mjs CHANGED
@@ -100,9 +100,186 @@ async function assertMarkerStoreConformance(store) {
100
100
  }
101
101
  done();
102
102
  }
103
+ function isPlainBag(v) {
104
+ return v !== null && typeof v === "object" && !Array.isArray(v);
105
+ }
106
+ function canonical(v) {
107
+ return JSON.stringify(v, (_k, val) => isPlainBag(val) ? Object.fromEntries(Object.keys(val).sort().map((k) => [k, val[k]])) : val);
108
+ }
109
+ function isProvider(p) {
110
+ return !!p && typeof p.load === "function" && typeof p.save === "function";
111
+ }
112
+ function policyEntryFailures(where, entry) {
113
+ if (!isPlainBag(entry)) return [`${where} is not an object`];
114
+ const out = [];
115
+ if (entry.lifetime !== "viewing" && entry.lifetime !== "durable") {
116
+ out.push(`${where}.lifetime is ${JSON.stringify(entry.lifetime)}, not "viewing" or "durable"`);
117
+ }
118
+ if (typeof entry.dropOnNewVersion !== "boolean") out.push(`${where}.dropOnNewVersion is not a boolean`);
119
+ if (typeof entry.dropOnFreshViewing !== "boolean") out.push(`${where}.dropOnFreshViewing is not a boolean`);
120
+ if (entry.durableWhen !== void 0 && (typeof entry.durableWhen !== "string" || !entry.durableWhen)) {
121
+ out.push(`${where}.durableWhen is not a preference name`);
122
+ }
123
+ if (entry.lifetime === "viewing" && entry.durableWhen !== void 0) {
124
+ out.push(`${where} has a durableWhen but a "viewing" lifetime, so the preference could never matter`);
125
+ }
126
+ return out;
127
+ }
128
+ function shapeFailures(service) {
129
+ if (!service || typeof service !== "object") return ["the service is missing"];
130
+ const out = [];
131
+ if (!isProvider(service.viewing)) out.push("viewing is missing, or has no load() or save()");
132
+ if (service.durable != null && !isProvider(service.durable)) out.push("durable has no load() or save()");
133
+ if (!isPlainBag(service.policy)) out.push("policy is missing, or is not an object keyed by view-state key");
134
+ else for (const k of Object.keys(service.policy)) out.push(...policyEntryFailures(`policy.${k}`, service.policy[k]));
135
+ if (service.chartKeys != null) out.push(...policyEntryFailures("chartKeys", service.chartKeys));
136
+ if (service.versionKey != null && (typeof service.versionKey !== "string" || !service.versionKey)) {
137
+ out.push("versionKey is not a key name");
138
+ }
139
+ if (service.durableMaxChars != null && !(typeof service.durableMaxChars === "number" && Number.isInteger(service.durableMaxChars) && service.durableMaxChars > 0)) {
140
+ out.push("durableMaxChars is not a positive whole number");
141
+ }
142
+ return out;
143
+ }
144
+ var PROBE_FIRST = {
145
+ __bicharts_conformance_view_a__: 1,
146
+ nested: { list: [1, "two", { three: 3 }], flag: true, none: null },
147
+ text: 'caf\xE9 "quoted"'
148
+ };
149
+ var PROBE_SECOND = { __bicharts_conformance_view_b__: ["only", "this"] };
150
+ async function assertViewStateConformance(service, options = {}) {
151
+ const failures = [];
152
+ const checked = [];
153
+ const skipped = [];
154
+ const finish = () => {
155
+ if (failures.length) throw new ConformanceError("ViewStateService", failures);
156
+ return { checked, skipped };
157
+ };
158
+ const broken = shapeFailures(service);
159
+ if (broken.length) {
160
+ failures.push(...broken);
161
+ finish();
162
+ }
163
+ checked.push("shape");
164
+ const flush = async (durable) => {
165
+ if (durable && options.settle) await options.settle();
166
+ };
167
+ const expectBag = (label, what, got, want) => {
168
+ if (!isPlainBag(got)) failures.push(`${label}: ${what} returned ${canonical(got) ?? String(got)}, not an object`);
169
+ else if (canonical(got) !== canonical(want)) {
170
+ failures.push(`${label}: ${what} returned ${canonical(got)}, not ${canonical(want)}`);
171
+ }
172
+ };
173
+ const checkStore = async (label, store, durable) => {
174
+ let before = "{}";
175
+ try {
176
+ const held = store.load();
177
+ if (!isPlainBag(held)) failures.push(`${label}: load() of a store with nothing set returned ${String(held)}, not an object`);
178
+ else before = canonical(held);
179
+ } catch (e) {
180
+ failures.push(`${label}: load() threw, and it must never: ${describeThrow(e)}`);
181
+ return;
182
+ }
183
+ try {
184
+ store.save({});
185
+ await flush(durable);
186
+ expectBag(label, "load() after save({})", store.load(), {});
187
+ store.save(JSON.parse(canonical(PROBE_FIRST)));
188
+ await flush(durable);
189
+ expectBag(label, "load() after save()", store.load(), PROBE_FIRST);
190
+ store.save(JSON.parse(canonical(PROBE_SECOND)));
191
+ await flush(durable);
192
+ const replaced = store.load();
193
+ if (isPlainBag(replaced) && canonical(replaced) !== canonical(PROBE_SECOND)) {
194
+ failures.push(`${label}: a second save() did not replace the first (a save takes the whole bag): load() returned ${canonical(replaced)}`);
195
+ } else if (!isPlainBag(replaced)) {
196
+ failures.push(`${label}: load() after a second save() returned ${String(replaced)}, not an object`);
197
+ }
198
+ store.save({});
199
+ await flush(durable);
200
+ const emptied = store.load();
201
+ if (!isPlainBag(emptied) || Object.keys(emptied).length) {
202
+ failures.push(`${label}: save({}) did not empty the store: load() returned ${canonical(emptied)}`);
203
+ }
204
+ } catch (e) {
205
+ failures.push(`${label}: threw during an ordinary load() or save(): ${describeThrow(e)}`);
206
+ } finally {
207
+ try {
208
+ store.save(JSON.parse(before));
209
+ await flush(durable);
210
+ } catch {
211
+ }
212
+ }
213
+ };
214
+ await checkStore("viewing", service.viewing, false);
215
+ checked.push("viewing: load, save, replace, round trip");
216
+ if (service.durable) {
217
+ await checkStore("durable", service.durable, true);
218
+ checked.push("durable: load, save, replace, round trip");
219
+ }
220
+ if (!options.recreate) {
221
+ skipped.push("the viewing store survives a host re-created on the same element (pass options.recreate)");
222
+ } else {
223
+ let before = "{}";
224
+ try {
225
+ before = canonical(service.viewing.load());
226
+ service.viewing.save(JSON.parse(canonical(PROBE_FIRST)));
227
+ const again = options.recreate();
228
+ if (!again || !isProvider(again.viewing)) failures.push("viewing: recreate() did not return a service with a viewing store");
229
+ else expectBag("viewing", "load() on a service re-created over the same element", again.viewing.load(), PROBE_FIRST);
230
+ } catch (e) {
231
+ failures.push(`viewing: threw while checking a re-created service: ${describeThrow(e)}`);
232
+ } finally {
233
+ try {
234
+ service.viewing.save(JSON.parse(before));
235
+ } catch {
236
+ }
237
+ }
238
+ checked.push("viewing: survives a host re-created on the same element");
239
+ }
240
+ if (service.durable) {
241
+ if (!options.reopen) {
242
+ skipped.push("the durable store survives a reopen and the viewing store starts clean (pass options.reopen)");
243
+ } else {
244
+ let before = "{}";
245
+ let viewingBefore = "{}";
246
+ try {
247
+ before = canonical(service.durable.load());
248
+ viewingBefore = canonical(service.viewing.load());
249
+ service.viewing.save(JSON.parse(canonical(PROBE_SECOND)));
250
+ service.durable.save(JSON.parse(canonical(PROBE_FIRST)));
251
+ await flush(true);
252
+ const reopened = options.reopen();
253
+ if (!reopened || !isProvider(reopened.durable)) {
254
+ failures.push("durable: reopen() did not return a service with a durable store");
255
+ } else {
256
+ expectBag("durable", "load() on a service reopened over the same saved file", reopened.durable.load(), PROBE_FIRST);
257
+ }
258
+ if (reopened && isProvider(reopened.viewing)) {
259
+ expectBag("viewing", "load() after a reopen (a viewing starts clean)", reopened.viewing.load(), {});
260
+ }
261
+ } catch (e) {
262
+ failures.push(`durable: threw while checking a reopened service: ${describeThrow(e)}`);
263
+ } finally {
264
+ try {
265
+ service.durable.save(JSON.parse(before));
266
+ await flush(true);
267
+ } catch {
268
+ }
269
+ try {
270
+ service.viewing.save(JSON.parse(viewingBefore));
271
+ } catch {
272
+ }
273
+ }
274
+ checked.push("durable: survives a reopen; viewing: starts clean after one");
275
+ }
276
+ }
277
+ return finish();
278
+ }
103
279
  export {
104
280
  ConformanceError,
105
281
  assertMarkerStoreConformance,
282
+ assertViewStateConformance,
106
283
  chartContainers,
107
284
  clickEmpty,
108
285
  clickMark,
@@ -1,4 +1,4 @@
1
- import type { RenderOptions } from "../contract";
1
+ import type { RenderOptions, ViewStateProvider } from "../contract";
2
2
  /** Signs an encoded request body. See @bicharts/shape-core's host/services.ts. */
3
3
  export interface WireSigner {
4
4
  sign(encodedBody: string): string;
@@ -112,6 +112,71 @@ export interface ThumbnailConsent {
112
112
  export interface SnapshotTarget {
113
113
  svg(): SVGSVGElement | null;
114
114
  }
115
+ /**
116
+ * How one key of the view-state bag lives and dies. A chart reads and writes its resting state
117
+ * (a sort, a frame, a 3D camera) through `options.uiState` / `options.setUiState`; the host
118
+ * decides what each key is allowed to outlive. A key that is not named in a service's `policy`
119
+ * is the chart's own, and follows `ViewStateService.chartKeys`.
120
+ */
121
+ export interface ViewStateKeyPolicy {
122
+ /**
123
+ * `"viewing"`: kept for this viewing only (it survives a redraw, never reaches the durable
124
+ * store, and a durable store that happens to hold it is not believed). `"durable"`: kept in
125
+ * the viewing store AND the durable store, so it survives a close and reopen.
126
+ */
127
+ lifetime: "viewing" | "durable";
128
+ /**
129
+ * Drop the key when the chart on screen is a different version from the one the key was set
130
+ * on. A 3D camera is aimed at one chart's axes; the next chart has no use for it.
131
+ */
132
+ dropOnNewVersion: boolean;
133
+ /**
134
+ * Drop the key when the bag is read at the start of a fresh viewing (a page opened, a file
135
+ * opened) instead of in the viewing that wrote it. A manual "stop" on an animation belongs
136
+ * to the viewing that made it, though the durable store may hold it.
137
+ */
138
+ dropOnFreshViewing: boolean;
139
+ /**
140
+ * A preference that must read true for this key to reach the durable store. When it reads
141
+ * anything else, the key still lives in the viewing store (the reader keeps their view while
142
+ * the page is open) and stays out of the durable one. An unset preference reads as off.
143
+ */
144
+ durableWhen?: string;
145
+ }
146
+ /**
147
+ * Where a host keeps a chart's resting view-state, and what each key may outlive. "Render"
148
+ * lifetime is the absence of a store: anything not saved dies with the redraw.
149
+ *
150
+ * `viewing` is required; `durable` is optional, and a host that has none (a static preview, a
151
+ * server-side render) is saying it cannot remember across a close. Every member besides the two
152
+ * stores is a rule the shared view-state functions apply; the stores themselves stay dumb.
153
+ */
154
+ export interface ViewStateService {
155
+ /** Survives a redraw (a resize, a cross-filter, a settings change). A page switch or a reopen starts clean. */
156
+ viewing: ViewStateProvider;
157
+ /** Survives close and reopen (a report file, a workbook). Absent or null: this host cannot. */
158
+ durable?: ViewStateProvider | null;
159
+ /** The keys the HOST writes or scopes, by name. */
160
+ policy: Readonly<Record<string, ViewStateKeyPolicy>>;
161
+ /**
162
+ * The rule for every key not named in `policy`: the ones the chart's own code writes.
163
+ * Absent: they are the chart's to keep (durable, and they survive a new version and a fresh
164
+ * viewing), because the chart validates what it reads. A host that forgets everything when
165
+ * the chart changes sets `dropOnNewVersion` here.
166
+ */
167
+ chartKeys?: ViewStateKeyPolicy | null;
168
+ /**
169
+ * The key under which the bag records the chart version it was last read for, so a version
170
+ * change is noticed even by a host that never saw the swap. Absent: the host applies the
171
+ * version rule itself, at the swap.
172
+ */
173
+ versionKey?: string | null;
174
+ /**
175
+ * The most characters of JSON the durable store accepts. A larger write is refused whole:
176
+ * nothing is saved, in either store, so the next read sees the state before it. Absent: no limit.
177
+ */
178
+ durableMaxChars?: number | null;
179
+ }
115
180
  /**
116
181
  * The whole services object a host constructs and passes to shared logic.
117
182
  *
@@ -128,6 +193,9 @@ export interface SnapshotTarget {
128
193
  * - theme: visual, add-in; a web page when it reads the browser's scheme; not the MCP server.
129
194
  * - thumbnails and snapshot: visual, add-in; not the MCP server (no DOM) or a demo page.
130
195
  * - diagnostics: visual, add-in; not the MCP server (stdio) or a demo page.
196
+ * - viewState: visual, add-in and a web page that passes a provider; the MCP preview passes an
197
+ * explicit no-op service. A host can remember across a close exactly when `viewState.durable`
198
+ * is present (see `viewStateIsDurable` in "@bicharts/chart-host/view-state").
131
199
  */
132
200
  export interface HostServices {
133
201
  signer: WireSigner;
@@ -142,4 +210,5 @@ export interface HostServices {
142
210
  thumbnails?: ThumbnailConsent | null;
143
211
  snapshot?: SnapshotTarget | null;
144
212
  diagnostics?: DiagnosticsSink | null;
213
+ viewState?: ViewStateService | null;
145
214
  }
@@ -44,7 +44,7 @@ export { pinScrolledAxis, unpinScrolledAxis, type AxisPinReport } from "./fitDom
44
44
  export { MIN_DELTA_E, pickDistinctColorFromSeed, buildPalette, buildPaletteFromSeed, orderMostDistinct, LEGIBLE_LIGHTNESS, type PaletteResult, type SeedForSlot, type PaletteOptions, } from "./palette";
45
45
  export { deltaEHex } from "./deltaE";
46
46
  export { resolveAutoColorScale, autoRampMinStep, autoRampCanvas, AUTO_RAMP_CLASSES, AUTO_RAMP_DRAWN_OPACITY, AUTO_RAMP_MIN_STEP_DELTA_E, type AutoColorScale, type AutoColorScaleInput } from "./autoRamp";
47
- export { PENDING_RECOVERY_WINDOW_MS, PENDING_RECOVERY_POLL_MS, PENDING_RECOVERY_FIRST_POLL_MS, PENDING_RECOVERY_FAST_PHASE_MS, PENDING_RECOVERY_MID_POLL_MS, PENDING_RECOVERY_SLOW_PHASE_MS, PENDING_RECOVERY_SLOW_POLL_MS, pendingRecoveryNextDelayMs, encodePendingGenerate, parsePendingGenerate, markerArmedBlind, isBlindMarker, servedCorrelationProves, sameMarker, decidePendingRecovery, recoveryPollRunning, recoveryOwnsTheSentence, latePickupAction, latePickupKeepsMarker, pollGiveUpKeepsMarker, pendingRecoveryRemainingMs, transportFailureShouldRecover, TRANSPORT_LOST_CHECKING_MESSAGE, GENERATION_CANCELLED_MESSAGE, recoveryAnswerIsCancelled, generationCancelledMessage, recoveryFinalOutcome, recoveryProvesNothingCharged, recoveryGiveUpRefunds, recoveryLooksUnreachable, recoveryFinalMessage, PENDING_RECOVERY_UNREACHABLE_POLLS, GENERATION_NOT_RECEIVED_MESSAGE, GENERATION_FAILED_MESSAGE, RECOVERY_UNREACHABLE_MESSAGE, type PendingGenerateMarker, type PendingRecoveryDecision, type LatePickupAction, type RecoveryFinalOutcome, } from "./pendingGenerate";
47
+ export { PENDING_RECOVERY_WINDOW_MS, PENDING_RECOVERY_POLL_MS, PENDING_RECOVERY_FIRST_POLL_MS, PENDING_RECOVERY_FAST_PHASE_MS, PENDING_RECOVERY_MID_POLL_MS, PENDING_RECOVERY_SLOW_PHASE_MS, PENDING_RECOVERY_SLOW_POLL_MS, pendingRecoveryNextDelayMs, encodePendingGenerate, parsePendingGenerate, markerArmedBlind, markerArmedAtMs, isBlindMarker, servedCorrelationProves, sameMarker, decidePendingRecovery, recoveryPollRunning, recoveryOwnsTheSentence, latePickupAction, latePickupKeepsMarker, pollGiveUpKeepsMarker, pendingRecoveryRemainingMs, transportFailureShouldRecover, TRANSPORT_LOST_CHECKING_MESSAGE, GENERATION_CANCELLED_MESSAGE, recoveryAnswerIsCancelled, generationCancelledMessage, recoveryFinalOutcome, recoveryProvesNothingCharged, recoveryGiveUpRefunds, recoveryLooksUnreachable, recoveryFinalMessage, PENDING_RECOVERY_UNREACHABLE_POLLS, GENERATION_NOT_RECEIVED_MESSAGE, GENERATION_FAILED_MESSAGE, GENERATION_LOST_IN_RESTART_MESSAGE, RECOVERY_UNREACHABLE_MESSAGE, type PendingGenerateMarker, type PendingRecoveryDecision, type LatePickupAction, type RecoveryFinalOutcome, } from "./pendingGenerate";
48
48
  export { stripJsComments } from "./codeComments";
49
49
  export { reconcileChartPick, type ChartPickReconciliation } from "./chartPick";
50
50
  export { isDuplicateGenerate, armGenerateGuard, DUPLICATE_GENERATE_WINDOW_MS, type GenerateGuardKey, type GenerateGuardState, } from "./duplicateGenerateGuard";
@@ -53,4 +53,4 @@ export { isNarrowedByFilter, isEmptiedByFilter, updateRowHighWater, type Narrowi
53
53
  export { shouldCaptureThumbnail, type ThumbnailGate, type ThumbnailDecision } from "./thumbnailPolicy";
54
54
  export { isPickableModelCode, markDefaultModel, defaultModelFirst, reconcileModelPick, DEFAULT_MODEL_SUFFIX, } from "./modelPicker";
55
55
  export { stableFingerprintOptions, fingerprintComponentDigest, type ClientIdSource } from "./clientIdentity";
56
- export type { HostServices, MarkerStore, PreferenceSource, HostTheme, ThemeSource, ThumbnailConsent, SnapshotTarget, WireSigner, WireTransport, WireResponse, CredentialTriple, CredentialSource, ViewportSource, Clock, DiagnosticEntry, DiagnosticsSink, RendererId, } from "./host/services";
56
+ export type { HostServices, MarkerStore, PreferenceSource, HostTheme, ThemeSource, ThumbnailConsent, SnapshotTarget, WireSigner, WireTransport, WireResponse, CredentialTriple, CredentialSource, ViewportSource, Clock, DiagnosticEntry, DiagnosticsSink, RendererId, ViewStateKeyPolicy, ViewStateService, } from "./host/services";
@@ -55,6 +55,17 @@ export type PendingRecoveryDecision = "none" | "delivered" | "expired" | "blind"
55
55
  * `v - 1` fallback says the same thing for markers written by older builds.
56
56
  */
57
57
  export declare function markerArmedBlind(m: PendingGenerateMarker | null | undefined): boolean;
58
+ /**
59
+ * WHEN THIS MARKER'S GENERATE STARTED, as a recovery poll states it: the marker's own `t`, epoch milliseconds on
60
+ * this host's clock, as a whole number - the `fetchArmedAtMs` a poll by correlation passes to `fetchFields`.
61
+ *
62
+ * With it the server can tell a generate that died with a server restart (started before the running process,
63
+ * no record of it) from one still running, and say so at the first poll instead of after the whole window.
64
+ * Nothing is shifted or checked against the clock here: a slow or fast host clock is the server's margin to
65
+ * absorb, and a future time it reads as absent. undefined for no marker or a time that is not a positive
66
+ * number, so the poll sends exactly what it always sent.
67
+ */
68
+ export declare function markerArmedAtMs(m: PendingGenerateMarker | null | undefined): number | undefined;
58
69
  /**
59
70
  * Can this marker be recovered AT ALL? Only by CORRELATION, so only if it carries one.
60
71
  *
@@ -198,7 +209,7 @@ export declare function generationCancelledMessage(serverMessage: string | null
198
209
  * beside it for older hosts, so this is read FIRST. null = not final. A final answer with an outcome
199
210
  * this build does not know reads as "failed": it is final, and it claims nothing about charges.
200
211
  */
201
- export type RecoveryFinalOutcome = "cancelled" | "failed" | "not-received";
212
+ export type RecoveryFinalOutcome = "cancelled" | "failed" | "not-received" | "lost-in-restart";
202
213
  export declare function recoveryFinalOutcome(r: {
203
214
  isPollFinal?: boolean | null;
204
215
  pollOutcome?: string | null;
@@ -207,7 +218,8 @@ export declare function recoveryFinalOutcome(r: {
207
218
  errorCode?: string | null;
208
219
  } | null | undefined): RecoveryFinalOutcome | null;
209
220
  /** Did the server PROVE nothing was charged? Only a cancel and a request it never received do; a
210
- * failed generation's charge is its own, and a host claims nothing about it. */
221
+ * failed generation's charge is its own, and a generate that died with a restart may have been charged
222
+ * before its record was written, so a host claims nothing about either. */
211
223
  export declare function recoveryProvesNothingCharged(o: string | null | undefined): boolean;
212
224
  /** Does a recovery that ended this way send the refund? Only when it ended with NOTHING PAINTED and
213
225
  * nothing proven - the window closed, the poll itself was refused, or the service could not be
@@ -222,6 +234,10 @@ export declare function recoveryLooksUnreachable(transportFailuresInARow: number
222
234
  * localises by code. */
223
235
  export declare const GENERATION_NOT_RECEIVED_MESSAGE = "The charting service has no record of that request, so nothing was generated and nothing was charged. Generate again.";
224
236
  export declare const GENERATION_FAILED_MESSAGE = "That chart didn't finish on the server, so there's nothing to show. Generate again.";
237
+ /** The process that was building the chart is gone, so nothing will arrive. Says what happened and what to
238
+ * do, and claims nothing about a charge: a free-tier meter can be charged before the generation's record
239
+ * exists, so a restart in between can leave a charge that no record shows. */
240
+ export declare const GENERATION_LOST_IN_RESTART_MESSAGE = "The charting service restarted while your chart was being built, so nothing was delivered. Click Generate to build it again.";
225
241
  /** Neither the generate's answer nor any check reached us. Claims nothing about a charge: behind a
226
242
  * proxy the generate may have arrived when the checks did not. */
227
243
  export declare const RECOVERY_UNREACHABLE_MESSAGE = "We couldn't reach the charting service to check on your chart - it may be offline, or blocked on this network. Try again in a moment.";
@@ -1,4 +1,4 @@
1
- import type { MarkerStore } from "../host/services";
1
+ import type { MarkerStore, ViewStateService } from "../host/services";
2
2
  /** Thrown by every conformance check. `failures` names each property the adapter broke. */
3
3
  export declare class ConformanceError extends Error {
4
4
  readonly contract: string;
@@ -12,4 +12,43 @@ export declare class ConformanceError extends Error {
12
12
  * before returning, whether or not the check passed.
13
13
  */
14
14
  export declare function assertMarkerStoreConformance(store: MarkerStore): Promise<void>;
15
+ /** What a host can hand `assertViewStateConformance` so it can exercise more of the contract. */
16
+ export interface ViewStateConformanceOptions {
17
+ /**
18
+ * Flushes a durable write that is debounced or asynchronous (a fake-timer tick, an awaited save),
19
+ * and is called after every write to the durable store before the check reads it back. A host whose
20
+ * durable `save()` lands synchronously omits it.
21
+ */
22
+ settle?: () => void | Promise<void>;
23
+ /**
24
+ * Builds the service again over the same element, the way the host does on a redraw. A viewing
25
+ * store must read there what the first one saved. Without it that property goes unchecked, and the
26
+ * report says so.
27
+ */
28
+ recreate?: () => ViewStateService;
29
+ /**
30
+ * Builds the service as a new viewing over the same saved file or workbook (a page switch, a
31
+ * reopen). The durable store must read what was saved, and the viewing store must start clean.
32
+ * Without it that property goes unchecked when there is a durable store, and the report says so.
33
+ */
34
+ reopen?: () => ViewStateService;
35
+ }
36
+ /** What a passing run covered. A failing run throws instead. */
37
+ export interface ViewStateConformanceReport {
38
+ checked: readonly string[];
39
+ /** Properties that need an option the caller did not pass. A host's own test can require this to be empty. */
40
+ skipped: readonly string[];
41
+ }
42
+ /**
43
+ * Checks a ViewStateService against the contract. A bag's store: `load()` never throws and reads `{}`
44
+ * when nothing is stored; `save()` takes the WHOLE bag and replaces what was there; nested values come
45
+ * back as written. The viewing store must survive the host being re-created on the same element (pass
46
+ * `recreate`). The durable store, when there is one, must hold what it was given, including across a
47
+ * reopen (pass `reopen`) where the viewing store starts clean. The policy must be well formed.
48
+ *
49
+ * Whatever the stores held before the check is put back before it returns, whether or not it passed.
50
+ * Resolves to a report of what was covered and what needed an option that was not passed; throws a
51
+ * ConformanceError listing every broken property otherwise.
52
+ */
53
+ export declare function assertViewStateConformance(service: ViewStateService, options?: ViewStateConformanceOptions): Promise<ViewStateConformanceReport>;
15
54
  export { chartContainers, marksOf, findMark, clickMark, clickEmpty, selectedRows } from "./interact";
@@ -0,0 +1,125 @@
1
+ import type { ViewStateKeyPolicy, ViewStateService } from "./host/services";
2
+ /**
3
+ * A service that stores nothing: a static preview, a server-side render, a thumbnail capture,
4
+ * where remembering would be wrong rather than merely absent (a thumbnail must be the same picture
5
+ * every time it is taken). Saying so out loud differs from passing no service at all, which leaves
6
+ * the choice to the host. The viewing store reads `{}` always and there is no durable store.
7
+ */
8
+ export declare function noopViewStateService(): ViewStateService;
9
+ /**
10
+ * Whether this host can remember a chart's view across a close and reopen. This is the one
11
+ * definition of "remembers the view": it is derived from the service, never declared beside it, so
12
+ * a host cannot claim it without a durable store to back the claim.
13
+ */
14
+ export declare function viewStateIsDurable(service: ViewStateService | null | undefined): boolean;
15
+ /**
16
+ * The rule for a key the chart's own code writes, when a service names no other. The chart keeps it:
17
+ * it reaches the durable store, and it survives a new version of the chart and a fresh viewing.
18
+ * Such a key can be stale (a sort on a column the new chart doesn't have), and that is the chart's to
19
+ * handle: whatever it reads from the bag is untrusted, and it validates it.
20
+ */
21
+ export declare const CHART_OWNED_KEY_POLICY: Readonly<ViewStateKeyPolicy>;
22
+ /** A key a rule took out of the bag a chart is handed, and why. */
23
+ export interface ViewStateDrop {
24
+ key: string;
25
+ reason: "fresh-viewing" | "new-version";
26
+ }
27
+ export interface ResolveViewStateOptions {
28
+ /** The version of the chart about to draw. Compared with what the bag recorded under `versionKey`. */
29
+ codeVersion?: number | string | null;
30
+ /**
31
+ * True when this viewing has not yet written a bag of its own (a page just opened, a file just
32
+ * opened). False when it has, which makes the viewing store the bag even if it reads empty, so a
33
+ * chart that cleared its state is not handed the older state the durable store still holds.
34
+ * Absent: a bag read from the durable store is a fresh viewing, and one read from the viewing store is not.
35
+ */
36
+ freshViewing?: boolean;
37
+ }
38
+ export interface ResolvedViewState {
39
+ /** The bag to hand the chart as `options.uiState`. The caller's own copy: nothing in a store aliases it. */
40
+ bag: Record<string, unknown>;
41
+ /** Where the bag was read from. `"none"`: neither store held anything. */
42
+ source: "viewing" | "durable" | "none";
43
+ /** Every key a rule took out, in the order the rules ran, so a host can log what it did. */
44
+ dropped: ViewStateDrop[];
45
+ }
46
+ /**
47
+ * BUILD THE BAG A CHART IS HANDED.
48
+ *
49
+ * Read order: the viewing store when it holds anything (or when the viewing has already written, see
50
+ * `freshViewing`), otherwise the durable store, otherwise nothing. The viewing store is the newer of
51
+ * the two within a viewing: a durable store can lag its own writes by a round trip, and a redraw in
52
+ * that window must see the write, not the state before it. A key whose lifetime is `"viewing"` is
53
+ * never believed from the durable store.
54
+ *
55
+ * Then two rules run, in this order, each over the keys a service's policy names (and, for the keys
56
+ * it doesn't, over `chartKeys`):
57
+ * - a fresh viewing drops every key marked `dropOnFreshViewing` whose value is set. A value that is
58
+ * already off (false, 0, "") stays: dropping it would change nothing the chart can tell.
59
+ * - a version change drops every key marked `dropOnNewVersion` that is present, and then records the
60
+ * current version under `versionKey` so the bag no longer reads as another chart's. Only when
61
+ * the service has a `versionKey`; a host without one applies the rule itself, with
62
+ * `viewStateAfterSwap`, wherever it learns the chart changed.
63
+ *
64
+ * The stamp is part of the bag the chart receives. The chart's own write carries it forward; a write
65
+ * that replaces the bag without it reads as another chart's on the next call.
66
+ *
67
+ * Never throws, and never changes a store: the bag is a copy the caller may let the chart mutate.
68
+ * A saved value that is not an object (a number, an array) is handed on as saved, with no rule run.
69
+ */
70
+ export declare function resolveViewState(service: ViewStateService, options?: ResolveViewStateOptions): ResolvedViewState;
71
+ /**
72
+ * WHEN THE CHART CHANGES UNDER A BAG THAT KEPT NO VERSION. Returns a copy of `bag` without the keys the
73
+ * policy marks `dropOnNewVersion` (and, if `chartKeys` marks it, without every key the chart wrote). A
74
+ * host that sees the swap itself (a regenerate, a version load, a cleared chart) calls it where it
75
+ * learns of it and saves what comes back. `versionKey` is left as it is: the next `resolveViewState`
76
+ * brings it up to date. A bag that is not an object, or nothing, comes back as `{}`.
77
+ */
78
+ export declare function viewStateAfterSwap(service: ViewStateService, bag: unknown): Record<string, unknown>;
79
+ /** What `commitViewState` did. */
80
+ export interface CommitViewStateResult {
81
+ /**
82
+ * `"saved"`: handed to the stores. `"ignored"`: not an object (or not serializable), so nothing was
83
+ * saved anywhere. `"too-large"`: the durable payload exceeded the service's `durableMaxChars`, so
84
+ * nothing was saved anywhere and the next read sees the state before this write.
85
+ */
86
+ outcome: "saved" | "ignored" | "too-large";
87
+ /** The viewing store's `save` ran without throwing. */
88
+ viewingSaved: boolean;
89
+ /** The durable store's `save` ran without throwing. False too when the service has no durable store. */
90
+ durableSaved: boolean;
91
+ /** Keys kept out of the durable store because their `durableWhen` preference is off, in policy order. */
92
+ withheld: string[];
93
+ /** Characters of JSON in the durable payload, measured only when the service sets a limit. */
94
+ durableChars: number | null;
95
+ /** A store that threw. The other store is still written, and the write never throws out of the chart. */
96
+ errors: Array<{
97
+ store: "viewing" | "durable";
98
+ error: unknown;
99
+ }>;
100
+ }
101
+ /**
102
+ * What the commit asks of a preference source: the value behind a switch's name. A host's own
103
+ * `PreferenceSource` (HostServices.prefs) satisfies it; so does `{ get: name => settings[name] }`.
104
+ */
105
+ export interface ViewStatePreferences {
106
+ get(key: string): unknown;
107
+ }
108
+ /**
109
+ * DECIDE WHAT A CHART'S WRITE REACHES.
110
+ *
111
+ * `next` is the whole bag the chart wrote (a write replaces; the chart merges its own siblings). The
112
+ * viewing store always gets all of it, so a reader who turns a 3D cube keeps their angle while the page
113
+ * is open whatever they have chosen to share. The durable store gets the same bag less the keys that
114
+ * must not reach it: a `"viewing"` lifetime, or a `durableWhen` preference that doesn't read true. The
115
+ * preference is read now, when the chart writes, not when the render began; an unset preference, a
116
+ * missing source or a source that throws all read as off, so a view is never shared by accident.
117
+ *
118
+ * The viewing store is written first and unconditionally, so a durable store that skips a write that
119
+ * changes nothing in its own payload (a camera-only change with the camera withheld) cannot cost the
120
+ * session the new pose. The one case where neither store is written is a refusal: a durable payload
121
+ * over `durableMaxChars` is dropped whole, so the next read sees the state before it.
122
+ *
123
+ * Never throws. A store that throws is reported in `errors` and the other store is still written.
124
+ */
125
+ export declare function commitViewState(next: unknown, service: ViewStateService, prefs?: ViewStatePreferences | null): CommitViewStateResult;
@@ -0,0 +1,171 @@
1
+ /*! @bicharts/chart-host — Apache-2.0. Bundled reference data: GeoNames (https://www.geonames.org/) CC BY 4.0; Natural Earth and US Census/TIGER (public domain). Full text: NOTICE in this package. */
2
+ import "./chunk-GHODWOAL.mjs";
3
+
4
+ // src/viewState.ts
5
+ function noopViewStateService() {
6
+ const nowhere = { load: () => ({}), save: () => {
7
+ } };
8
+ return { viewing: nowhere, durable: null, policy: {} };
9
+ }
10
+ function viewStateIsDurable(service) {
11
+ return !!service && !!service.durable;
12
+ }
13
+ var CHART_OWNED_KEY_POLICY = Object.freeze({
14
+ lifetime: "durable",
15
+ dropOnNewVersion: false,
16
+ dropOnFreshViewing: false
17
+ });
18
+ var own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
19
+ function isBag(v) {
20
+ return v !== null && typeof v === "object" && !Array.isArray(v);
21
+ }
22
+ function holds(v) {
23
+ return isBag(v) ? Object.keys(v).length > 0 : !!v;
24
+ }
25
+ function loadOrEmpty(store) {
26
+ try {
27
+ const bag = store ? store.load() : void 0;
28
+ return bag == null ? {} : bag;
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+ function put(into, key, value) {
34
+ Object.defineProperty(into, key, { value, enumerable: true, writable: true, configurable: true });
35
+ }
36
+ function copyOf(v, depth = 0) {
37
+ if (v === null || typeof v !== "object" || depth > 64) return v;
38
+ if (Array.isArray(v)) return v.map((x) => copyOf(x, depth + 1));
39
+ const proto = Object.getPrototypeOf(v);
40
+ if (proto !== Object.prototype && proto !== null) return v;
41
+ const out = {};
42
+ for (const k of Object.keys(v)) put(out, k, copyOf(v[k], depth + 1));
43
+ return out;
44
+ }
45
+ function policyFor(service, key) {
46
+ if (service.policy && own(service.policy, key)) return service.policy[key];
47
+ return service.chartKeys ?? CHART_OWNED_KEY_POLICY;
48
+ }
49
+ function resolveViewState(service, options = {}) {
50
+ const viewed = loadOrEmpty(service.viewing);
51
+ let source;
52
+ let raw;
53
+ if (holds(viewed) || options.freshViewing === false) {
54
+ source = "viewing";
55
+ raw = holds(viewed) ? viewed : {};
56
+ } else {
57
+ const stored = loadOrEmpty(service.durable);
58
+ source = holds(stored) ? "durable" : "none";
59
+ raw = holds(stored) ? stored : {};
60
+ if (isBag(raw)) {
61
+ const believed = {};
62
+ for (const k of Object.keys(raw)) if (policyFor(service, k).lifetime !== "viewing") put(believed, k, raw[k]);
63
+ raw = believed;
64
+ }
65
+ }
66
+ const bag = copyOf(raw);
67
+ const dropped = [];
68
+ if (!isBag(bag)) return { bag, source, dropped };
69
+ const versionKey = service.versionKey || null;
70
+ const fresh = options.freshViewing ?? source !== "viewing";
71
+ if (fresh) {
72
+ for (const key of Object.keys(bag)) {
73
+ if (key === versionKey) continue;
74
+ if (policyFor(service, key).dropOnFreshViewing && bag[key]) {
75
+ delete bag[key];
76
+ dropped.push({ key, reason: "fresh-viewing" });
77
+ }
78
+ }
79
+ }
80
+ if (versionKey) {
81
+ const version = options.codeVersion ?? null;
82
+ if (bag[versionKey] !== version) {
83
+ for (const key of Object.keys(bag)) {
84
+ if (key === versionKey) continue;
85
+ if (policyFor(service, key).dropOnNewVersion) {
86
+ delete bag[key];
87
+ dropped.push({ key, reason: "new-version" });
88
+ }
89
+ }
90
+ put(bag, versionKey, version);
91
+ }
92
+ }
93
+ return { bag, source, dropped };
94
+ }
95
+ function viewStateAfterSwap(service, bag) {
96
+ const copy = copyOf(bag);
97
+ if (!isBag(copy)) return {};
98
+ const versionKey = service.versionKey || null;
99
+ for (const key of Object.keys(copy)) {
100
+ if (key !== versionKey && policyFor(service, key).dropOnNewVersion) delete copy[key];
101
+ }
102
+ return copy;
103
+ }
104
+ function preferenceOn(prefs, name) {
105
+ try {
106
+ return !!(prefs && prefs.get(name));
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+ function commitViewState(next, service, prefs) {
112
+ const result = {
113
+ outcome: "saved",
114
+ viewingSaved: false,
115
+ durableSaved: false,
116
+ withheld: [],
117
+ durableChars: null,
118
+ errors: []
119
+ };
120
+ if (next == null || typeof next !== "object") return { ...result, outcome: "ignored" };
121
+ const bag = next;
122
+ const durable = service.durable || null;
123
+ let forFile = bag;
124
+ if (durable && isBag(bag)) {
125
+ let trimmed = null;
126
+ const withheld = /* @__PURE__ */ new Set();
127
+ for (const key of Object.keys(bag)) {
128
+ const policy = policyFor(service, key);
129
+ const keep = policy.lifetime !== "viewing" && (policy.durableWhen === void 0 || preferenceOn(prefs, policy.durableWhen));
130
+ if (keep) continue;
131
+ trimmed ??= { ...bag };
132
+ delete trimmed[key];
133
+ if (policy.lifetime !== "viewing") withheld.add(key);
134
+ }
135
+ if (trimmed) forFile = trimmed;
136
+ const declared = Object.keys(service.policy || {});
137
+ result.withheld = [...declared.filter((k) => withheld.has(k)), ...[...withheld].filter((k) => !declared.includes(k))];
138
+ }
139
+ if (durable && service.durableMaxChars) {
140
+ try {
141
+ result.durableChars = JSON.stringify(forFile).length;
142
+ } catch (error) {
143
+ result.errors.push({ store: "durable", error });
144
+ return { ...result, outcome: "ignored" };
145
+ }
146
+ if (result.durableChars > service.durableMaxChars) return { ...result, outcome: "too-large" };
147
+ }
148
+ try {
149
+ service.viewing.save(bag);
150
+ result.viewingSaved = true;
151
+ } catch (error) {
152
+ result.errors.push({ store: "viewing", error });
153
+ }
154
+ if (durable) {
155
+ try {
156
+ durable.save(forFile);
157
+ result.durableSaved = true;
158
+ } catch (error) {
159
+ result.errors.push({ store: "durable", error });
160
+ }
161
+ }
162
+ return result;
163
+ }
164
+ export {
165
+ CHART_OWNED_KEY_POLICY,
166
+ commitViewState,
167
+ noopViewStateService,
168
+ resolveViewState,
169
+ viewStateAfterSwap,
170
+ viewStateIsDurable
171
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.6.81",
3
+ "version": "0.6.83",
4
4
  "description": "Run a BIC-generated D3 chart in any web host: compiles the generated render() function, applies the shared option defaults, resolves mark clicks (through tooltip overlays), owns the selection affordance, and translates row indices between cross-filtered charts. The same contract the BIC Power BI visual implements, minus Power BI. React bindings at @bicharts/chart-host/react.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,6 +49,11 @@
49
49
  "import": "./dist/card.mjs",
50
50
  "default": "./dist/card.mjs"
51
51
  },
52
+ "./view-state": {
53
+ "types": "./dist/types/viewState.d.ts",
54
+ "import": "./dist/view-state.mjs",
55
+ "default": "./dist/view-state.mjs"
56
+ },
52
57
  "./adapters": {
53
58
  "types": "./dist/types/adapters/index.d.ts",
54
59
  "import": "./dist/adapters.mjs",