"vscode:/vscode.git/clone" did not exist on "a58383c582e54856f1849dbc21ff34b37f837b9e"
Commit 74d15e77 authored by Matthias Betz's avatar Matthias Betz
Browse files

add store and pure state functions



Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent f5a166fa
// Application state and the pure functions that change it. The state holds
// small plain data only; geometry goes from the loader to the viewer directly.
//
// {
// layers: [{ id, name, status: 'loading' | 'ready' | 'error', progress,
// error, lods, vertexCount }],
// hidden: { lod: [] }, // filter dimension -> hidden values
// selection: null // reserved for picking
// }
// A LOD is 0..4, or null for geometry outside any lodN element.
export function initialState() {
return { layers: [], hidden: { lod: [] }, selection: null };
}
// Order of LODs: 0..4, then null ("no LOD").
export function compareLods(a, b) {
if (a === b) {
return 0;
}
if (a === null) {
return 1;
}
if (b === null) {
return -1;
}
return a - b;
}
// Applies change(layer) to the layer with layerId. Unknown ids (e.g. events
// of a load that was replaced) leave the state object unchanged.
function updateLayer(state, layerId, change) {
const index = state.layers.findIndex(layer => layer.id === layerId);
if (index < 0) {
return state;
}
const layers = state.layers.slice();
layers[index] = { ...layers[index], ...change(layers[index]) };
return { ...state, layers };
}
// Starts a new layer. For now a newly loaded file replaces the loaded one.
export function addLayer(state, { id, name }) {
const layer = { id, name, status: 'loading', progress: 0, error: null, lods: [], vertexCount: 0 };
return { ...state, layers: [layer], selection: null };
}
export function reportProgress(state, layerId, progress) {
return updateLayer(state, layerId, () => ({ progress: Math.min(1, Math.max(0, progress)) }));
}
export function reportLod(state, layerId, lod) {
const layer = state.layers.find(l => l.id === layerId);
if (layer === undefined || layer.lods.includes(lod)) {
return state;
}
return updateLayer(state, layerId, l => ({ lods: [...l.lods, lod].sort(compareLods) }));
}
export function finishLayer(state, layerId, vertexCount) {
return updateLayer(state, layerId, () => ({ status: 'ready', progress: 1, vertexCount }));
}
export function failLayer(state, layerId, message) {
return updateLayer(state, layerId, () => ({ status: 'error', error: message }));
}
// Hides (hidden = true) or shows a value of a filter dimension, e.g. ('lod', 2).
export function setHidden(state, dimension, value, hidden) {
const current = state.hidden[dimension] || [];
if (current.includes(value) === hidden) {
return state;
}
const next = hidden ? [...current, value] : current.filter(v => v !== value);
return { ...state, hidden: { ...state.hidden, [dimension]: next } };
}
export function isVisible(state, dimension, value) {
return !(state.hidden[dimension] || []).includes(value);
}
// Holds the application state. update() replaces it with fn(state, ...args)
// and notifies the subscribers with (state, previousState). An update that
// returns the same object notifies nobody.
export function createStore(initial) {
let state = initial;
const listeners = new Set();
return {
get() {
return state;
},
update(fn, ...args) {
const previous = state;
const next = fn(previous, ...args);
if (next === previous) {
return;
}
state = next;
for (const listener of [...listeners]) {
listener(state, previous);
}
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
import test from 'node:test';
import assert from 'node:assert/strict';
import {
initialState, addLayer, reportProgress, reportLod, finishLayer, failLayer,
setHidden, isVisible, compareLods,
} from '../public/src/app/state.js';
const loading = () => addLayer(initialState(), { id: 1, name: 'a.gml' });
test('the initial state has no layers, nothing hidden and no selection', () => {
assert.deepEqual(initialState(), { layers: [], hidden: { lod: [] }, selection: null });
});
test('addLayer adds a loading layer and replaces the previous one', () => {
const state = addLayer(loading(), { id: 2, name: 'b.gml' });
assert.deepEqual(state.layers, [
{ id: 2, name: 'b.gml', status: 'loading', progress: 0, error: null, lods: [], vertexCount: 0 },
]);
});
test('reportProgress stores the progress clamped to 0..1', () => {
assert.equal(reportProgress(loading(), 1, 0.25).layers[0].progress, 0.25);
assert.equal(reportProgress(loading(), 1, 1.5).layers[0].progress, 1);
});
test('reportLod keeps LODs sorted 0..4 with "no LOD" last and ignores repeats', () => {
let state = loading();
for (const lod of [2, null, 1, 2]) state = reportLod(state, 1, lod);
assert.deepEqual(state.layers[0].lods, [1, 2, null]);
});
test('finishLayer marks the layer ready with its vertex count', () => {
const layer = finishLayer(reportProgress(loading(), 1, 0.5), 1, 600).layers[0];
assert.deepEqual([layer.status, layer.progress, layer.vertexCount], ['ready', 1, 600]);
});
test('failLayer marks the layer as failed with the message', () => {
const layer = failLayer(loading(), 1, 'The file is a 7z archive').layers[0];
assert.deepEqual([layer.status, layer.error], ['error', 'The file is a 7z archive']);
});
test('events of a replaced layer leave the state object unchanged', () => {
// A late callback of an aborted load must not touch the new layer.
const state = addLayer(loading(), { id: 2, name: 'b.gml' });
assert.equal(reportProgress(state, 1, 0.9), state);
assert.equal(reportLod(state, 1, 2), state);
assert.equal(finishLayer(state, 1, 10), state);
assert.equal(failLayer(state, 1, 'x'), state);
});
test('setHidden hides and shows values per dimension', () => {
let state = setHidden(initialState(), 'lod', 2, true);
assert.equal(isVisible(state, 'lod', 2), false);
assert.equal(isVisible(state, 'lod', 1), true);
state = setHidden(state, 'lod', 2, false);
assert.equal(isVisible(state, 'lod', 2), true);
});
test('setHidden without a change returns the same state object', () => {
const state = initialState();
assert.equal(setHidden(state, 'lod', 1, false), state);
const hidden = setHidden(state, 'lod', 1, true);
assert.equal(setHidden(hidden, 'lod', 1, true), hidden);
});
test('setHidden works for dimensions that do not exist yet', () => {
const state = setHidden(initialState(), 'featureType', 'LandUse', true);
assert.equal(isVisible(state, 'featureType', 'LandUse'), false);
});
test('hidden values survive replacing the layer', () => {
const state = addLayer(setHidden(loading(), 'lod', 2, true), { id: 2, name: 'b.gml' });
assert.equal(isVisible(state, 'lod', 2), false);
});
test('compareLods orders 0..4 and puts null last', () => {
assert.deepEqual([null, 3, 0, 2].sort(compareLods), [0, 2, 3, null]);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from '../public/src/app/store.js';
test('update replaces the state and notifies with the previous state', () => {
const store = createStore({ n: 1 });
const calls = [];
store.subscribe((state, previous) => calls.push([state.n, previous.n]));
store.update((s, add) => ({ n: s.n + add }), 2);
assert.equal(store.get().n, 3);
assert.deepEqual(calls, [[3, 1]]);
});
test('an update that returns the same state object notifies nobody', () => {
const store = createStore({ n: 1 });
let calls = 0;
store.subscribe(() => calls++);
store.update(s => s);
assert.equal(calls, 0);
});
test('unsubscribed listeners are not called anymore', () => {
const store = createStore({ n: 1 });
let calls = 0;
const unsubscribe = store.subscribe(() => calls++);
unsubscribe();
store.update(s => ({ n: s.n + 1 }));
assert.equal(calls, 0);
});
test('a listener that unsubscribes during notification does not break the others', () => {
const store = createStore({ n: 1 });
const seen = [];
const first = store.subscribe(() => { seen.push('first'); first(); });
store.subscribe(() => seen.push('second'));
store.update(s => ({ n: 2 }));
assert.deepEqual(seen, ['first', 'second']);
});
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment