@bicharts/chart-host 0.6.81 → 0.6.82

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/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
  }
@@ -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";
@@ -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.82",
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",