mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement fit-mode rendering contract with container sizing and pane re-fit
This commit is contained in:
@@ -14,19 +14,84 @@ describe('prepareSpecForRender', () => {
|
||||
const spec = {
|
||||
data: { values: [{ a: 1 }] },
|
||||
mark: 'bar',
|
||||
width: 200,
|
||||
height: 100,
|
||||
encoding: { x: { field: 'a', type: 'quantitative' } },
|
||||
};
|
||||
const before = structuredClone(spec);
|
||||
const out = prepareSpecForRender(spec, { fitMode: 'width' });
|
||||
|
||||
// Mutating the output must not touch the input.
|
||||
(out as { mark: string }).mark = 'point';
|
||||
// A mode that both sets and removes sizing — the most invasive transform.
|
||||
prepareSpecForRender(spec, { fitMode: 'width' });
|
||||
expect(spec).toEqual(before);
|
||||
});
|
||||
|
||||
test('M1 is a faithful pass-through of the spec content', () => {
|
||||
test('Original (default) leaves sizing untouched', () => {
|
||||
const spec = { $schema: 'x', mark: 'line', width: 200, height: 100 };
|
||||
expect(prepareSpecForRender(spec)).toEqual(spec);
|
||||
expect(prepareSpecForRender(spec, { fitMode: 'default' })).toEqual(spec);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareSpecForRender — fit modes (spec §04 Rendering Contract step 2)', () => {
|
||||
const base = { mark: 'bar', width: 200, height: 100 };
|
||||
|
||||
test('Width: width→container, height removed', () => {
|
||||
const out = prepareSpecForRender(base, { fitMode: 'width' }) as Record<string, unknown>;
|
||||
expect(out.width).toBe('container');
|
||||
expect('height' in out).toBe(false);
|
||||
});
|
||||
|
||||
test('Height: height→container, width removed', () => {
|
||||
const out = prepareSpecForRender(base, { fitMode: 'height' }) as Record<string, unknown>;
|
||||
expect(out.height).toBe('container');
|
||||
expect('width' in out).toBe(false);
|
||||
});
|
||||
|
||||
test('Full: both dimensions→container', () => {
|
||||
const out = prepareSpecForRender(base, { fitMode: 'full' }) as Record<string, unknown>;
|
||||
expect(out.width).toBe('container');
|
||||
expect(out.height).toBe('container');
|
||||
});
|
||||
|
||||
test('adds container sizing even when the spec declares no width/height', () => {
|
||||
const out = prepareSpecForRender({ mark: 'point' }, { fitMode: 'full' }) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(out).toEqual({ mark: 'point', width: 'container', height: 'container' });
|
||||
});
|
||||
|
||||
test('recurses into layered sub-specs', () => {
|
||||
const spec = {
|
||||
layer: [
|
||||
{ mark: 'bar', width: 50, height: 50 },
|
||||
{ mark: 'line', height: 50 },
|
||||
],
|
||||
};
|
||||
const out = prepareSpecForRender(spec, { fitMode: 'full' }) as unknown as {
|
||||
width: string;
|
||||
height: string;
|
||||
layer: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(out.width).toBe('container');
|
||||
expect(out.height).toBe('container');
|
||||
expect(out.layer[0]).toMatchObject({ width: 'container', height: 'container' });
|
||||
expect(out.layer[1]).toMatchObject({ width: 'container', height: 'container' });
|
||||
});
|
||||
|
||||
test('recurses into concat arrays and a child spec (facet/repeat)', () => {
|
||||
const spec = {
|
||||
facet: { field: 'g', type: 'nominal' },
|
||||
spec: {
|
||||
hconcat: [{ mark: 'bar', height: 80 }, { mark: 'point' }],
|
||||
},
|
||||
};
|
||||
const out = prepareSpecForRender(spec, { fitMode: 'width' }) as unknown as {
|
||||
spec: { hconcat: Array<Record<string, unknown>> };
|
||||
};
|
||||
for (const child of out.spec.hconcat) {
|
||||
expect(child.width).toBe('container');
|
||||
expect('height' in child).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+57
-8
@@ -8,21 +8,69 @@
|
||||
* never mutated by rendering:
|
||||
*
|
||||
* 1. Dataset reference resolution — arrives in M3 (no-op here).
|
||||
* 2. Fit-mode sizing — arrives in M2 (no-op here).
|
||||
* 2. Fit-mode sizing — implemented in M2.
|
||||
*
|
||||
* In M1 it is an identity transform over a copy: it establishes the
|
||||
* copy-not-mutate invariant and the call site the renderer depends on, so M2/M3
|
||||
* can fill in the steps without the preview pipeline changing shape.
|
||||
* The copy-not-mutate invariant and the call site the renderer depends on are
|
||||
* fixed; M3 fills in step 1 without the preview pipeline changing shape.
|
||||
*/
|
||||
|
||||
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
|
||||
export type FitMode = 'default' | 'width' | 'height' | 'full';
|
||||
|
||||
export interface PrepareOptions {
|
||||
/** Active fit mode. Applied in M2; ignored in M1. */
|
||||
/** Active fit mode. Defaults to `'default'` (Original — spec sizing untouched). */
|
||||
fitMode?: FitMode;
|
||||
}
|
||||
|
||||
/** The container/sub-spec keys the rendering contract recurses into (spec §04). */
|
||||
const CHILD_ARRAY_KEYS = ['layer', 'concat', 'hconcat', 'vconcat'] as const;
|
||||
|
||||
/** A spec node we might rewrite sizing on; loose by design (any Vega-Lite spec). */
|
||||
type SpecNode = Record<string, unknown>;
|
||||
|
||||
function isSpecNode(value: unknown): value is SpecNode {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one node's sizing to the fit mode (spec §04 → Rendering Contract,
|
||||
* step 2). `'container'` is Vega-Lite's responsive keyword; the unconstrained
|
||||
* dimension is removed so it recomputes naturally.
|
||||
*/
|
||||
function applyFitToNode(node: SpecNode, mode: FitMode): void {
|
||||
switch (mode) {
|
||||
case 'width':
|
||||
node.width = 'container';
|
||||
delete node.height;
|
||||
break;
|
||||
case 'height':
|
||||
node.height = 'container';
|
||||
delete node.width;
|
||||
break;
|
||||
case 'full':
|
||||
node.width = 'container';
|
||||
node.height = 'container';
|
||||
break;
|
||||
// 'default' (Original) leaves sizing untouched and never reaches here.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the fit mode to a spec and every nested sub-spec it recurses into —
|
||||
* layered (`layer`) and concatenated (`concat`/`hconcat`/`vconcat`) children,
|
||||
* and a parent spec's single child `spec` (facet/repeat). Mutates in place; the
|
||||
* caller (`prepareSpecForRender`) already works on a copy.
|
||||
*/
|
||||
function applyFitMode(node: unknown, mode: FitMode): void {
|
||||
if (!isSpecNode(node)) return;
|
||||
applyFitToNode(node, mode);
|
||||
for (const key of CHILD_ARRAY_KEYS) {
|
||||
const children = node[key];
|
||||
if (Array.isArray(children)) for (const child of children) applyFitMode(child, mode);
|
||||
}
|
||||
if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather
|
||||
* than a nested-property accessor (docs/architecture/05 §4). Used wherever
|
||||
@@ -37,11 +85,12 @@ export function escapeVegaField(name: string): string {
|
||||
* Transform the authored spec into the spec to embed. Operates on a deep copy
|
||||
* and returns it; the input is never mutated.
|
||||
*/
|
||||
export function prepareSpecForRender<T>(spec: T, _options: PrepareOptions = {}): T {
|
||||
export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}): T {
|
||||
const copy = structuredClone(spec);
|
||||
|
||||
// M3: resolveDatasetRefs(copy, datasets)
|
||||
// M2: applyFitMode(copy, options.fitMode)
|
||||
// 1. M3: resolveDatasetRefs(copy, datasets)
|
||||
// 2. Fit-mode sizing.
|
||||
applyFitMode(copy, options.fitMode ?? 'default');
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user