From bb1e23eaaed08c02aa3bfee48b8634aacfea629d Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 20 Oct 2026 10:11:27 +0200 Subject: [PATCH 1/6] feat(scalarbarModel): Enable picking on scalarbar for models [skip ci] --- app/components/HybridRenderingView.vue | 1 + app/components/RemoteRenderingView.vue | 1 + .../Viewer/Options/ColormapQuickPicker.vue | 6 +- app/composables/global_attribute_style.ts | 283 +++++++++--------- app/composables/use_quick_colormap.ts | 5 +- app/utils/global_attribute_components.ts | 112 +++++++ .../global_attribute_style.nuxt.test.ts | 51 ++++ 7 files changed, 309 insertions(+), 150 deletions(-) create mode 100644 app/utils/global_attribute_components.ts create mode 100644 tests/unit/composables/global_attribute_style.nuxt.test.ts diff --git a/app/components/HybridRenderingView.vue b/app/components/HybridRenderingView.vue index da29ec4d..a6293026 100644 --- a/app/components/HybridRenderingView.vue +++ b/app/components/HybridRenderingView.vue @@ -102,6 +102,7 @@ async function handleClick(event: PointerEvent): Promise { :x="quickColormap.x" :y="quickColormap.y" :data-id="quickColormap.data_id" + :block-ids="quickColormap.block_ids" /> diff --git a/app/components/RemoteRenderingView.vue b/app/components/RemoteRenderingView.vue index 1f5fc412..36915c70 100644 --- a/app/components/RemoteRenderingView.vue +++ b/app/components/RemoteRenderingView.vue @@ -120,6 +120,7 @@ onMounted(async () => { :x="quickColormap.x" :y="quickColormap.y" :data-id="quickColormap.data_id" + :block-ids="quickColormap.block_ids" /> diff --git a/app/components/Viewer/Options/ColormapQuickPicker.vue b/app/components/Viewer/Options/ColormapQuickPicker.vue index d4772ce6..41624a6a 100644 --- a/app/components/Viewer/Options/ColormapQuickPicker.vue +++ b/app/components/Viewer/Options/ColormapQuickPicker.vue @@ -8,15 +8,17 @@ import { useGlobalAttributeStyle } from "@ogw_front/composables/global_attribute interface Props { dataId?: string; + blockIds?: number[]; x: number; y: number; } -const { dataId = undefined, x, y } = defineProps(); +const { dataId = undefined, blockIds = undefined, x, y } = defineProps(); const show = defineModel("show", { default: false }); const dataIdRef = computed(() => dataId); +const blockIdsRef = computed(() => blockIds); const { currentColormap, currentRange, @@ -25,7 +27,7 @@ const { currentTimeSteps, currentTimeStep, loadTimeSteps, -} = useGlobalAttributeStyle(dataIdRef); +} = useGlobalAttributeStyle(dataIdRef, blockIdsRef); watch(show, async (isShown) => { if (isShown) { diff --git a/app/composables/global_attribute_style.ts b/app/composables/global_attribute_style.ts index 3a4bf80e..94d08007 100644 --- a/app/composables/global_attribute_style.ts +++ b/app/composables/global_attribute_style.ts @@ -1,22 +1,20 @@ +import { + ATTRIBUTE_COLORINGS, + type ActiveComponent, + type ModelComponent, + attributeParams, + attributeSchema, + attributeTypeOf, + meshActiveComponents, +} from "@ogw_front/utils/global_attribute_components"; import type { JsonRpcSchema } from "@ogw_shared/utils/types"; -import back_schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schemas.js"; import { fetchAttributeRange } from "@ogw_front/utils/attribute_range"; import { getAttributeRange } from "@ogw_front/utils/attributes"; import { useBackStore } from "@ogw_front/stores/back"; +import { useDataStore } from "@ogw_front/stores/data"; import { useDataStyleStore } from "@ogw_front/stores/data_style"; import { useHybridViewerStore } from "@ogw_front/stores/hybrid_viewer"; -interface ComponentNameEntry { - getterKey: string; - setterKey: string; - key: string; -} - -interface ActiveComponent extends ComponentNameEntry { - activeColoring: string; - attributeType: string; -} - // This composable resolves a batch of dynamically-named getter/setter methods on the data style store (e.g. "meshPointsVertexAttributeRange", "setMeshPointsVertexAttributeRange", ...) - the store's real, fully-typed surface has no index signature for this, so dynamic lookups go through this type guard instead of an unsafe cast of the whole store (which mixes these methods with state refs and other members), matching how these methods are actually shaped (getters take an id and return a value, setters take an id plus arbitrary arguments). type DynamicStoreMethod = (...args: readonly unknown[]) => unknown; @@ -29,10 +27,6 @@ function getDynamicStoreMethod(store: object, key: string): DynamicStoreMethod | return isDynamicStoreMethod(value) ? value : undefined; } -interface AttributeStyleComponent { - coloring?: { active?: string }; -} - interface AttributeResponse { readonly attributes?: readonly { readonly attribute_name?: string; @@ -40,12 +34,6 @@ interface AttributeResponse { }[]; } -function isColoringValue( - value: unknown, -): value is NonNullable { - return typeof value === "object" && value !== null; -} - function isJsonRpcSchema(value: unknown): value is JsonRpcSchema { return ( typeof value === "object" && value !== null && "$id" in value && typeof value.$id === "string" @@ -68,7 +56,10 @@ function isNumberOrUndefined(value: unknown): value is number | undefined { return value === undefined || typeof value === "number"; } -export function useGlobalAttributeStyle(dataIdRef: Ref): { +export function useGlobalAttributeStyle( + dataIdRef: Ref, + componentViewerIdsRef: Ref = ref(undefined), +): { currentColormap: typeof currentColormap; currentRange: typeof currentRange; applyGlobalColormap: typeof applyGlobalColormap; @@ -78,68 +69,91 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { loadTimeSteps: typeof loadTimeSteps; } { const dataStyleStore = useDataStyleStore(); + const dataStore = useDataStore(); const hybridViewerStore = useHybridViewerStore(); const backStore = useBackStore(); - const componentNames: ComponentNameEntry[] = [ - { getterKey: "meshPoints", setterKey: "MeshPoints", key: "points" }, - { getterKey: "meshEdges", setterKey: "MeshEdges", key: "edges" }, - { getterKey: "meshPolygons", setterKey: "MeshPolygons", key: "polygons" }, - { getterKey: "meshCells", setterKey: "MeshCells", key: "cells" }, - { getterKey: "meshPolyhedra", setterKey: "MeshPolyhedra", key: "polyhedra" }, - ]; + // Components of the model colored by the picked scalar bar, empty for a mesh + const modelComponents = shallowRef([]); - function getActiveComponents(targetId: string): ActiveComponent[] { - const style = dataStyleStore.getStyle(targetId); - const activeComponents: ActiveComponent[] = []; - for (const { key, getterKey, setterKey } of componentNames) { - let componentStyle: Record | undefined = undefined; - switch (key) { - case "points": { - componentStyle = style.points; - break; - } - case "edges": { - componentStyle = style.edges; - break; - } - case "polygons": { - componentStyle = style.polygons; - break; - } - case "cells": { - componentStyle = style.cells; - break; - } - case "polyhedra": { - componentStyle = style.polyhedra; - break; - } - default: { - componentStyle = undefined; - } - } - if (componentStyle === undefined) { - continue; - } + async function resolveModelComponents(): Promise { + const modelId = dataIdRef.value; + const viewerIds = componentViewerIdsRef.value ?? []; + if (modelId === undefined || modelId === "" || viewerIds.length === 0) { + modelComponents.value = []; + return; + } + const components = await Promise.all( + viewerIds.map(async (viewerId) => { + const component = await dataStore.getComponentByViewerId(modelId, viewerId); + return component; + }), + ); + modelComponents.value = components + .filter((component) => component !== undefined) + .map((component) => ({ type: component.type, geodeId: component.geode_id })); + } - const { coloring } = componentStyle; - if (!isColoringValue(coloring)) { + // Groups the model components by type and active coloring: each group uses the same store methods + function getModelActiveComponents(modelId: string): ActiveComponent[] { + const groups = new Map(); + for (const { type, geodeId } of modelComponents.value) { + const coloringGetter = getDynamicStoreMethod(dataStyleStore, `model${type}ActiveColoring`); + const activeColoring = coloringGetter?.(modelId, geodeId); + if (typeof activeColoring !== "string" || !ATTRIBUTE_COLORINGS.has(activeColoring)) { continue; } + const key = `${type}_${activeColoring}`; + const group = groups.get(key) ?? { + activeColoring, + attributeType: attributeTypeOf(activeColoring), + getterKey: `model${type}s`, + setterKey: `Model${type}s`, + key, + componentIds: [], + }; + group.componentIds?.push(geodeId); + groups.set(key, group); + } + return [...groups.values()]; + } - const { active: activeColoring } = coloring; - if ( - activeColoring === undefined || - !["vertex", "edge", "polygon", "cell", "polyhedron"].includes(activeColoring) - ) { - continue; - } + function getActiveComponents(targetId: string): ActiveComponent[] { + return modelComponents.value.length > 0 + ? getModelActiveComponents(targetId) + : meshActiveComponents(dataStyleStore.getStyle(targetId)); + } - const attributeType = `${activeColoring.charAt(0).toUpperCase()}${activeColoring.slice(1)}Attribute`; - activeComponents.push({ activeColoring, attributeType, getterKey, setterKey, key }); + // Mesh getters take the data id, model getters also take one of the component ids + function readStyle(targetId: string, comp: ActiveComponent, property: string): unknown { + const getter = getDynamicStoreMethod( + dataStyleStore, + `${comp.getterKey}${comp.attributeType}${property}`, + ); + if (!getter) { + return undefined; } - return activeComponents; + return comp.componentIds ? getter(targetId, comp.componentIds[0]) : getter(targetId); + } + + // Mesh setters take the data id, model setters also take the component ids. False if no setter. + async function writeStyle( + targetId: string, + comp: ActiveComponent, + property: string, + ...values: unknown[] + ): Promise { + const setter = getDynamicStoreMethod( + dataStyleStore, + `set${comp.setterKey}${comp.attributeType}${property}`, + ); + if (!setter) { + return false; + } + await (comp.componentIds + ? setter(targetId, comp.componentIds, ...values) + : setter(targetId, ...values)); + return true; } const currentColormap = computed((): string => { @@ -149,13 +163,9 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { } for (const comp of getActiveComponents(targetId)) { - const getterName = `${comp.getterKey}${comp.attributeType}ColorMap`; - const getter = getDynamicStoreMethod(dataStyleStore, getterName); - if (getter) { - const colorMap = getter(targetId); - if (typeof colorMap === "string" && colorMap.length > 0) { - return colorMap; - } + const colorMap = readStyle(targetId, comp, "ColorMap"); + if (typeof colorMap === "string" && colorMap.length > 0) { + return colorMap; } } @@ -168,16 +178,19 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { return; } - let updated = false; - for (const comp of getActiveComponents(targetId)) { - const setterName = `set${comp.setterKey}${comp.attributeType}Range`; - const setter = getDynamicStoreMethod(dataStyleStore, setterName); - if (setter) { - setter(targetId, newValue[0], newValue[1]); - updated = true; - } - } - if (updated) { + const written = await Promise.all( + getActiveComponents(targetId).map(async (comp) => { + const componentWritten = await writeStyle( + targetId, + comp, + "Range", + newValue[0], + newValue[1], + ); + return componentWritten; + }), + ); + if (written.includes(true)) { await hybridViewerStore.remoteRender(); } } @@ -190,14 +203,10 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { } for (const comp of getActiveComponents(targetId)) { - const getterName = `${comp.getterKey}${comp.attributeType}Range`; - const getter = getDynamicStoreMethod(dataStyleStore, getterName); - if (getter) { - const rawRange = getter(targetId); - const range = isNumberArray(rawRange) ? rawRange : undefined; - if (range && range.length === 2) { - return [range[0] ?? 0, range[1] ?? 1]; - } + const rawRange = readStyle(targetId, comp, "Range"); + const range = isNumberArray(rawRange) ? rawRange : undefined; + if (range && range.length === 2) { + return [range[0] ?? 0, range[1] ?? 1]; } } return [0, 1]; @@ -215,18 +224,11 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { return; } - const promises: Promise[] = []; - - for (const comp of getActiveComponents(targetId)) { - const setterName = `set${comp.setterKey}${comp.attributeType}ColorMap`; - const setter = getDynamicStoreMethod(dataStyleStore, setterName); - - if (setter) { - promises.push(Promise.resolve(setter(targetId, newMap))); - } - } - - await Promise.all(promises); + await Promise.all( + getActiveComponents(targetId).map(async (comp) => { + await writeStyle(targetId, comp, "ColorMap", newMap); + }), + ); await hybridViewerStore.remoteRender(); } @@ -239,43 +241,32 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { const requestPromises: Promise[] = []; for (const comp of getActiveComponents(targetId)) { - const { activeColoring, attributeType, getterKey, setterKey } = comp; - - const nameGetter = getDynamicStoreMethod(dataStyleStore, `${getterKey}${attributeType}Name`); - const itemGetter = getDynamicStoreMethod(dataStyleStore, `${getterKey}${attributeType}Item`); - if (!nameGetter || !itemGetter) { - continue; - } - - const rawAttrName = nameGetter(targetId); + const rawAttrName = readStyle(targetId, comp, "Name"); const attrName = isStringOrUndefined(rawAttrName) ? rawAttrName : undefined; - const rawAttrItem = itemGetter(targetId); + const rawAttrItem = readStyle(targetId, comp, "Item"); const attrItem = (isNumberOrUndefined(rawAttrItem) ? rawAttrItem : undefined) ?? 0; if (attrName === undefined || attrName === "") { continue; } - const rangeSchema = (back_schemas.opengeodeweb_back as Record)[ - `${activeColoring}_attribute_range` - ]; + const rangeSchema = attributeSchema(comp, "range"); if (!isJsonRpcSchema(rangeSchema)) { continue; } requestPromises.push( (async (): Promise => { - const range = await fetchAttributeRange(rangeSchema, { id: targetId }, attrName); + const range = await fetchAttributeRange( + rangeSchema, + attributeParams(targetId, comp), + attrName, + ); if (!range) { return; } const { min, max } = getAttributeRange(range, attrItem); - const setter = getDynamicStoreMethod( - dataStyleStore, - `set${setterKey}${attributeType}Range`, - ); - if (setter) { - setter(targetId, min, max); + if (await writeStyle(targetId, comp, "Range", min, max)) { await hybridViewerStore.remoteRender(); } })(), @@ -289,12 +280,8 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { const temporalComponent = shallowRef(undefined); async function requestTimeSteps(targetId: string, comp: ActiveComponent): Promise { - const { activeColoring, attributeType, getterKey } = comp; - const nameGetter = getDynamicStoreMethod(dataStyleStore, `${getterKey}${attributeType}Name`); - const rawAttrName = nameGetter?.(targetId); - const schema = (back_schemas.opengeodeweb_back as Record)[ - `${activeColoring}_attribute_names` - ]; + const rawAttrName = readStyle(targetId, comp, "Name"); + const schema = attributeSchema(comp, "names"); if ( !isStringOrUndefined(rawAttrName) || rawAttrName === undefined || @@ -305,7 +292,7 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { } let timeSteps: number[] = []; await backStore.request( - { schema, params: { id: targetId } }, + { schema, params: attributeParams(targetId, comp) }, { response_function: (response: unknown): void => { if (!isAttributeResponse(response)) { @@ -337,7 +324,14 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { } } - watch(dataIdRef, loadTimeSteps, { immediate: true }); + watch( + [dataIdRef, componentViewerIdsRef], + async () => { + await resolveModelComponents(); + await loadTimeSteps(); + }, + { immediate: true }, + ); async function applyCurrentTimeStep(step: number): Promise { const targetId = dataIdRef.value; @@ -345,10 +339,7 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { if (targetId === undefined || targetId === "" || !comp) { return; } - const { attributeType, setterKey } = comp; - const setter = getDynamicStoreMethod(dataStyleStore, `set${setterKey}${attributeType}TimeStep`); - if (setter) { - await setter(targetId, step); + if (await writeStyle(targetId, comp, "TimeStep", step)) { await hybridViewerStore.remoteRender(); } } @@ -360,9 +351,7 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { if (targetId === undefined || targetId === "" || !comp) { return 0; } - const { attributeType, getterKey } = comp; - const getter = getDynamicStoreMethod(dataStyleStore, `${getterKey}${attributeType}TimeStep`); - const rawStep = getter?.(targetId); + const rawStep = readStyle(targetId, comp, "TimeStep"); return typeof rawStep === "number" ? rawStep : 0; }, // Setter cannot be async (see `currentRange`), so the work is delegated to a helper. diff --git a/app/composables/use_quick_colormap.ts b/app/composables/use_quick_colormap.ts index efe7639b..2c88b3c1 100644 --- a/app/composables/use_quick_colormap.ts +++ b/app/composables/use_quick_colormap.ts @@ -9,11 +9,13 @@ export function useQuickColormap(): { const viewerStore = useViewerStore(); const quickColormap = reactive<{ data_id: string | undefined; + block_ids: number[] | undefined; show: boolean; x: number; y: number; }>({ data_id: undefined, + block_ids: undefined, show: false, x: 0, y: 0, @@ -28,9 +30,10 @@ export function useQuickColormap(): { try { const schema = viewer_schemas.opengeodeweb_viewer.viewer.pick_colormap; const params = { x: offsetX, y: offsetY }; - const { data_id } = await viewerStore.request({ schema, params }); + const { data_id, block_ids } = await viewerStore.request({ schema, params }); if (data_id !== undefined && data_id !== "") { quickColormap.data_id = data_id; + quickColormap.block_ids = block_ids; quickColormap.x = clientX; quickColormap.y = clientY; quickColormap.show = true; diff --git a/app/utils/global_attribute_components.ts b/app/utils/global_attribute_components.ts new file mode 100644 index 00000000..482b5574 --- /dev/null +++ b/app/utils/global_attribute_components.ts @@ -0,0 +1,112 @@ +import back_schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schemas.js"; + +interface ComponentNameEntry { + getterKey: string; + setterKey: string; + key: string; +} + +interface ActiveComponent extends ComponentNameEntry { + activeColoring: string; + attributeType: string; + // Geode ids of the model components sharing this coloring, undefined for a mesh + componentIds?: string[]; +} + +interface ModelComponent { + type: string; + geodeId: string; +} + +const ATTRIBUTE_COLORINGS = new Set(["vertex", "edge", "polygon", "cell", "polyhedron"]); + +function attributeTypeOf(activeColoring: string): string { + return `${activeColoring.charAt(0).toUpperCase()}${activeColoring.slice(1)}Attribute`; +} + +const MESH_COMPONENT_NAMES: ComponentNameEntry[] = [ + { getterKey: "meshPoints", setterKey: "MeshPoints", key: "points" }, + { getterKey: "meshEdges", setterKey: "MeshEdges", key: "edges" }, + { getterKey: "meshPolygons", setterKey: "MeshPolygons", key: "polygons" }, + { getterKey: "meshCells", setterKey: "MeshCells", key: "cells" }, + { getterKey: "meshPolyhedra", setterKey: "MeshPolyhedra", key: "polyhedra" }, +]; + +interface MeshStyle { + points?: Record; + edges?: Record; + polygons?: Record; + cells?: Record; + polyhedra?: Record; +} + +function isColoringValue(value: unknown): value is { active?: string } { + return typeof value === "object" && value !== null; +} + +function meshComponentStyle(style: MeshStyle, key: string): Record | undefined { + switch (key) { + case "points": { + return style.points; + } + case "edges": { + return style.edges; + } + case "polygons": { + return style.polygons; + } + case "cells": { + return style.cells; + } + case "polyhedra": { + return style.polyhedra; + } + default: { + return undefined; + } + } +} + +// The mesh element types colored by an attribute, from the mesh style +function meshActiveComponents(style: MeshStyle): ActiveComponent[] { + const activeComponents: ActiveComponent[] = []; + for (const { key, getterKey, setterKey } of MESH_COMPONENT_NAMES) { + const coloring = meshComponentStyle(style, key)?.coloring; + if (!isColoringValue(coloring)) { + continue; + } + const { active: activeColoring } = coloring; + if (activeColoring === undefined || !ATTRIBUTE_COLORINGS.has(activeColoring)) { + continue; + } + const attributeType = attributeTypeOf(activeColoring); + activeComponents.push({ activeColoring, attributeType, getterKey, setterKey, key }); + } + return activeComponents; +} + +// The *_attribute_names or *_attribute_range schema of the component coloring +function attributeSchema(comp: ActiveComponent, route: "names" | "range"): unknown { + const prefix = comp.componentIds ? "model_component_" : ""; + return (back_schemas.opengeodeweb_back as Record)[ + `${prefix}${comp.activeColoring}_attribute_${route}` + ]; +} + +function attributeParams( + targetId: string, + comp: ActiveComponent, +): { id: string; component_ids?: string[] } { + return comp.componentIds ? { id: targetId, component_ids: comp.componentIds } : { id: targetId }; +} + +export { + ATTRIBUTE_COLORINGS, + type ActiveComponent, + type ComponentNameEntry, + type ModelComponent, + attributeParams, + attributeSchema, + attributeTypeOf, + meshActiveComponents, +}; diff --git a/tests/unit/composables/global_attribute_style.nuxt.test.ts b/tests/unit/composables/global_attribute_style.nuxt.test.ts new file mode 100644 index 00000000..bc9723c9 --- /dev/null +++ b/tests/unit/composables/global_attribute_style.nuxt.test.ts @@ -0,0 +1,51 @@ +// Third party imports +import { describe, expect, test, vi } from "vitest"; +import { flushPromises } from "@vue/test-utils"; + +// Local imports +import { useGlobalAttributeStyle } from "@ogw_front/composables/global_attribute_style"; + +const MODEL_ID = "model"; +const BLOCK_VIEWER_ID = 48; +const BLOCK_GEODE_ID = "block"; + +const setColorMap = vi.fn<(modelId: string, blockIds: string[], colorMap: string) => boolean>(); +const remoteRender = vi.fn<() => undefined>(); + +vi.mock(import("@ogw_front/stores/data_style") as Promise, () => ({ + useDataStyleStore: (): Record => ({ + modelBlockActiveColoring: (): string => "polyhedron", + modelBlocksPolyhedronAttributeColorMap: (): string => "batlow", + setModelBlocksPolyhedronAttributeColorMap: setColorMap, + }), +})); +vi.mock(import("@ogw_front/stores/data") as Promise, () => ({ + useDataStore: (): Record => ({ + getComponentByViewerId: (): { type: string; geode_id: string } => ({ + type: "Block", + geode_id: BLOCK_GEODE_ID, + }), + }), +})); +vi.mock(import("@ogw_front/stores/hybrid_viewer") as Promise, () => ({ + useHybridViewerStore: (): Record => ({ remoteRender }), +})); +vi.mock(import("@ogw_front/stores/back") as Promise, () => ({ + useBackStore: (): Record => ({ request: vi.fn<() => undefined>() }), +})); + +describe("global attribute style", () => { + test("applies the colormap to the model blocks of the picked scalar bar", async () => { + const { currentColormap, applyGlobalColormap } = useGlobalAttributeStyle( + ref(MODEL_ID), + ref([BLOCK_VIEWER_ID]), + ); + await flushPromises(); + + expect(currentColormap.value).toBe("batlow"); + await applyGlobalColormap("viridis"); + + expect(setColorMap).toHaveBeenCalledWith(MODEL_ID, [BLOCK_GEODE_ID], "viridis"); + expect(remoteRender).toHaveBeenCalledWith(); + }); +}); From af898bd6102d850640836ab91290dbed32c21195 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 20 Oct 2026 10:24:42 +0200 Subject: [PATCH 2/6] trigger From 71cc952285a4d554597e78121a0b9b9773a29e1a Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 20 Oct 2026 14:23:12 +0200 Subject: [PATCH 3/6] isolate responsability between mesh and models for picking on scalar bars --- app/components/HybridRenderingView.vue | 1 + app/components/RemoteRenderingView.vue | 1 + .../Viewer/Options/ColormapQuickPicker.vue | 11 +- app/composables/attribute_coloring_style.ts | 182 ++++++++ app/composables/global_attribute_style.ts | 404 +++--------------- app/composables/model_attribute_style.ts | 79 ++++ app/stores/data.ts | 12 + app/utils/global_attribute_components.ts | 112 ----- ....ts => model_attribute_style.nuxt.test.ts} | 18 +- 9 files changed, 346 insertions(+), 474 deletions(-) create mode 100644 app/composables/attribute_coloring_style.ts create mode 100644 app/composables/model_attribute_style.ts delete mode 100644 app/utils/global_attribute_components.ts rename tests/unit/composables/{global_attribute_style.nuxt.test.ts => model_attribute_style.nuxt.test.ts} (73%) diff --git a/app/components/HybridRenderingView.vue b/app/components/HybridRenderingView.vue index a6293026..bee68dbb 100644 --- a/app/components/HybridRenderingView.vue +++ b/app/components/HybridRenderingView.vue @@ -98,6 +98,7 @@ async function handleClick(event: PointerEvent): Promise {
{
(); const show = defineModel("show", { default: false }); const dataIdRef = computed(() => dataId); -const blockIdsRef = computed(() => blockIds); +// The parent keys this picker on the picked data: a mesh or a model, so the style is chosen once here const { currentColormap, currentRange, @@ -27,7 +28,13 @@ const { currentTimeSteps, currentTimeStep, loadTimeSteps, -} = useGlobalAttributeStyle(dataIdRef, blockIdsRef); +} = + blockIds === undefined + ? useGlobalAttributeStyle(dataIdRef) + : useModelAttributeStyle( + dataIdRef, + computed(() => blockIds), + ); watch(show, async (isShown) => { if (isShown) { diff --git a/app/composables/attribute_coloring_style.ts b/app/composables/attribute_coloring_style.ts new file mode 100644 index 00000000..72093e75 --- /dev/null +++ b/app/composables/attribute_coloring_style.ts @@ -0,0 +1,182 @@ +import { type AttributeSchemas, fetchAttributeRange } from "@ogw_front/utils/attribute_range"; +import type { JsonRpcSchema } from "@ogw_shared/utils/types"; +import back_schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schemas.js"; +import { getAttributeRange } from "@ogw_front/utils/attributes"; +import { useBackStore } from "@ogw_front/stores/back"; +import { useHybridViewerStore } from "@ogw_front/stores/hybrid_viewer"; + +// Elements colored by the same attribute, with the store and back accesses of their data +interface AttributeColoring { + // Reads a style property ("Name", "Range", ...) of the attribute coloring + read: (property: string) => unknown; + write: (property: string, ...values: unknown[]) => Promise; + schemas: AttributeSchemas; + params: { id: string; component_ids?: string[] }; +} + +interface AttributeNamesResponse { + attributes: { attribute_name: string; time_steps?: number[] }[]; +} + +interface AttributeColoringStyle { + currentColormap: ComputedRef; + currentRange: WritableComputedRef; + applyGlobalColormap: (colorMap: string) => Promise; + resetGlobalRange: () => Promise; + currentTimeSteps: Ref; + currentTimeStep: WritableComputedRef; + loadTimeSteps: () => Promise; +} + +type StoreMethod = (...args: readonly unknown[]) => unknown; + +// The data style store methods are named after the element and attribute types, e.g. "meshPointsVertexAttributeRange" +function storeMethod(store: object, name: string): StoreMethod { + const method: unknown = Reflect.get(store, name); + // oxlint-disable-next-line no-unsafe-type-assertion -- the style methods exist for every element type and attribute coloring + return method as StoreMethod; +} + +// "Polyhedron" in the store method names of the "polyhedron" coloring +function attributeType(activeColoring: string): string { + return `${activeColoring.charAt(0).toUpperCase()}${activeColoring.slice(1)}Attribute`; +} + +function attributeSchema(prefix: string, activeColoring: string, route: string): JsonRpcSchema { + const schema: unknown = Reflect.get( + back_schemas.opengeodeweb_back, + `${prefix}${activeColoring}_attribute_${route}`, + ); + // oxlint-disable-next-line no-unsafe-type-assertion -- these schemas exist for every attribute coloring + return schema as JsonRpcSchema; +} + +// The `${prefix}${activeColoring}_attribute_names` and `_attribute_range` schemas +function attributeSchemas(prefix: string, activeColoring: string): AttributeSchemas { + return { + names: attributeSchema(prefix, activeColoring, "names"), + range: attributeSchema(prefix, activeColoring, "range"), + }; +} + +// Colormap, range and time step of the attributes colored by a scalar bar +function useAttributeColoringStyle(colorings: Ref): AttributeColoringStyle { + const backStore = useBackStore(); + const hybridViewerStore = useHybridViewerStore(); + + const currentTimeSteps = ref([]); + const temporalColoring = shallowRef(undefined); + + async function writeAll(property: string, ...values: unknown[]): Promise { + await Promise.all( + colorings.value.map(async (coloring) => { + await coloring.write(property, ...values); + }), + ); + await hybridViewerStore.remoteRender(); + } + + const currentColormap = computed((): string => { + const [coloring] = colorings.value; + return coloring ? String(coloring.read("ColorMap")) : "batlow"; + }); + + async function applyGlobalColormap(colorMap: string): Promise { + await writeAll("ColorMap", colorMap); + } + + const currentRange = computed({ + get(): readonly [number, number] { + const [coloring] = colorings.value; + if (!coloring) { + return [0, 1]; + } + // oxlint-disable-next-line no-unsafe-type-assertion -- the range getters return [minimum, maximum]. + const [minimum = 0, maximum = 1] = coloring.read("Range") as (number | undefined)[]; + return [minimum, maximum]; + }, + // Vue's WritableComputedOptions setter is strictly `(v: T) => void`: the async write is delegated. + set: (range: readonly [number, number]): void => { + /* oxlint-disable-next-line promise/prefer-await-to-then -- setter cannot be async; see comment above. */ + writeAll("Range", range[0], range[1]).catch(() => undefined); + }, + }); + + async function resetGlobalRange(): Promise { + await Promise.all( + colorings.value.map(async (coloring) => { + const range = await fetchAttributeRange( + coloring.schemas.range, + coloring.params, + String(coloring.read("Name")), + ); + const { min, max } = getAttributeRange(range, Number(coloring.read("Item") ?? 0)); + await coloring.write("Range", min, max); + }), + ); + await hybridViewerStore.remoteRender(); + } + + async function requestTimeSteps(coloring: AttributeColoring): Promise { + const name = coloring.read("Name"); + const response = await backStore.request({ + schema: coloring.schemas.names, + params: coloring.params, + }); + // oxlint-disable-next-line no-unsafe-type-assertion -- every *_attribute_names schema shares this response. + const { attributes } = response as AttributeNamesResponse; + return attributes.find((attribute) => attribute.attribute_name === name)?.time_steps ?? []; + } + + async function loadTimeSteps(): Promise { + temporalColoring.value = undefined; + currentTimeSteps.value = []; + for (const coloring of colorings.value) { + // oxlint-disable-next-line no-await-in-loop -- first temporal coloring wins, in order. + const timeSteps = await requestTimeSteps(coloring); + if (timeSteps.length > 0) { + temporalColoring.value = coloring; + currentTimeSteps.value = timeSteps; + return; + } + } + } + + async function applyTimeStep(step: number): Promise { + if (temporalColoring.value) { + await temporalColoring.value.write("TimeStep", step); + await hybridViewerStore.remoteRender(); + } + } + + const currentTimeStep = computed({ + get(): number { + const coloring = temporalColoring.value; + return coloring ? Number(coloring.read("TimeStep") ?? 0) : 0; + }, + // Setter cannot be async (see `currentRange`), so the write is delegated. + set: (step: number): void => { + /* oxlint-disable-next-line promise/prefer-await-to-then -- setter cannot be async; see comment above. */ + applyTimeStep(step).catch(() => undefined); + }, + }); + + return { + currentColormap, + currentRange, + applyGlobalColormap, + resetGlobalRange, + currentTimeSteps, + currentTimeStep, + loadTimeSteps, + }; +} + +export { + type AttributeColoring, + type AttributeColoringStyle, + attributeSchemas, + attributeType, + storeMethod, + useAttributeColoringStyle, +}; diff --git a/app/composables/global_attribute_style.ts b/app/composables/global_attribute_style.ts index 94d08007..b284f528 100644 --- a/app/composables/global_attribute_style.ts +++ b/app/composables/global_attribute_style.ts @@ -1,373 +1,73 @@ import { - ATTRIBUTE_COLORINGS, - type ActiveComponent, - type ModelComponent, - attributeParams, - attributeSchema, - attributeTypeOf, - meshActiveComponents, -} from "@ogw_front/utils/global_attribute_components"; -import type { JsonRpcSchema } from "@ogw_shared/utils/types"; -import { fetchAttributeRange } from "@ogw_front/utils/attribute_range"; -import { getAttributeRange } from "@ogw_front/utils/attributes"; -import { useBackStore } from "@ogw_front/stores/back"; -import { useDataStore } from "@ogw_front/stores/data"; + type AttributeColoring, + type AttributeColoringStyle, + attributeSchemas, + attributeType, + storeMethod, + useAttributeColoringStyle, +} from "@ogw_front/composables/attribute_coloring_style"; import { useDataStyleStore } from "@ogw_front/stores/data_style"; -import { useHybridViewerStore } from "@ogw_front/stores/hybrid_viewer"; -// This composable resolves a batch of dynamically-named getter/setter methods on the data style store (e.g. "meshPointsVertexAttributeRange", "setMeshPointsVertexAttributeRange", ...) - the store's real, fully-typed surface has no index signature for this, so dynamic lookups go through this type guard instead of an unsafe cast of the whole store (which mixes these methods with state refs and other members), matching how these methods are actually shaped (getters take an id and return a value, setters take an id plus arbitrary arguments). -type DynamicStoreMethod = (...args: readonly unknown[]) => unknown; - -function isDynamicStoreMethod(value: unknown): value is DynamicStoreMethod { - return typeof value === "function"; +interface MeshElement { + // Element type of the store methods, e.g. "MeshPoints" for "setMeshPointsVertexAttributeRange" + storeName: string; + styleKey: "points" | "edges" | "polygons" | "cells" | "polyhedra"; } -function getDynamicStoreMethod(store: object, key: string): DynamicStoreMethod | undefined { - const value: unknown = Reflect.get(store, key); - return isDynamicStoreMethod(value) ? value : undefined; -} +const MESH_ELEMENTS: MeshElement[] = [ + { storeName: "MeshPoints", styleKey: "points" }, + { storeName: "MeshEdges", styleKey: "edges" }, + { storeName: "MeshPolygons", styleKey: "polygons" }, + { storeName: "MeshCells", styleKey: "cells" }, + { storeName: "MeshPolyhedra", styleKey: "polyhedra" }, +]; -interface AttributeResponse { - readonly attributes?: readonly { - readonly attribute_name?: string; - readonly time_steps?: readonly number[]; - }[]; -} - -function isJsonRpcSchema(value: unknown): value is JsonRpcSchema { - return ( - typeof value === "object" && value !== null && "$id" in value && typeof value.$id === "string" - ); -} +const ATTRIBUTE_COLORINGS = new Set(["vertex", "edge", "polygon", "cell", "polyhedron"]); -function isAttributeResponse(value: unknown): value is AttributeResponse { +function isColoring(value: unknown): value is { active?: string } { return typeof value === "object" && value !== null; } -function isNumberArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((item) => typeof item === "number"); -} - -function isStringOrUndefined(value: unknown): value is string | undefined { - return value === undefined || typeof value === "string"; -} - -function isNumberOrUndefined(value: unknown): value is number | undefined { - return value === undefined || typeof value === "number"; -} - +// Styles the mesh elements colored by an attribute export function useGlobalAttributeStyle( dataIdRef: Ref, - componentViewerIdsRef: Ref = ref(undefined), -): { - currentColormap: typeof currentColormap; - currentRange: typeof currentRange; - applyGlobalColormap: typeof applyGlobalColormap; - resetGlobalRange: typeof resetGlobalRange; - currentTimeSteps: typeof currentTimeSteps; - currentTimeStep: typeof currentTimeStep; - loadTimeSteps: typeof loadTimeSteps; -} { +): AttributeColoringStyle { const dataStyleStore = useDataStyleStore(); - const dataStore = useDataStore(); - const hybridViewerStore = useHybridViewerStore(); - const backStore = useBackStore(); - - // Components of the model colored by the picked scalar bar, empty for a mesh - const modelComponents = shallowRef([]); - - async function resolveModelComponents(): Promise { - const modelId = dataIdRef.value; - const viewerIds = componentViewerIdsRef.value ?? []; - if (modelId === undefined || modelId === "" || viewerIds.length === 0) { - modelComponents.value = []; - return; - } - const components = await Promise.all( - viewerIds.map(async (viewerId) => { - const component = await dataStore.getComponentByViewerId(modelId, viewerId); - return component; - }), - ); - modelComponents.value = components - .filter((component) => component !== undefined) - .map((component) => ({ type: component.type, geodeId: component.geode_id })); - } - - // Groups the model components by type and active coloring: each group uses the same store methods - function getModelActiveComponents(modelId: string): ActiveComponent[] { - const groups = new Map(); - for (const { type, geodeId } of modelComponents.value) { - const coloringGetter = getDynamicStoreMethod(dataStyleStore, `model${type}ActiveColoring`); - const activeColoring = coloringGetter?.(modelId, geodeId); - if (typeof activeColoring !== "string" || !ATTRIBUTE_COLORINGS.has(activeColoring)) { - continue; - } - const key = `${type}_${activeColoring}`; - const group = groups.get(key) ?? { - activeColoring, - attributeType: attributeTypeOf(activeColoring), - getterKey: `model${type}s`, - setterKey: `Model${type}s`, - key, - componentIds: [], - }; - group.componentIds?.push(geodeId); - groups.set(key, group); - } - return [...groups.values()]; - } - - function getActiveComponents(targetId: string): ActiveComponent[] { - return modelComponents.value.length > 0 - ? getModelActiveComponents(targetId) - : meshActiveComponents(dataStyleStore.getStyle(targetId)); - } - - // Mesh getters take the data id, model getters also take one of the component ids - function readStyle(targetId: string, comp: ActiveComponent, property: string): unknown { - const getter = getDynamicStoreMethod( - dataStyleStore, - `${comp.getterKey}${comp.attributeType}${property}`, - ); - if (!getter) { - return undefined; - } - return comp.componentIds ? getter(targetId, comp.componentIds[0]) : getter(targetId); - } - - // Mesh setters take the data id, model setters also take the component ids. False if no setter. - async function writeStyle( - targetId: string, - comp: ActiveComponent, - property: string, - ...values: unknown[] - ): Promise { - const setter = getDynamicStoreMethod( - dataStyleStore, - `set${comp.setterKey}${comp.attributeType}${property}`, - ); - if (!setter) { - return false; - } - await (comp.componentIds - ? setter(targetId, comp.componentIds, ...values) - : setter(targetId, ...values)); - return true; - } - - const currentColormap = computed((): string => { - const targetId = dataIdRef.value; - if (targetId === undefined || targetId === "") { - return "batlow"; - } - - for (const comp of getActiveComponents(targetId)) { - const colorMap = readStyle(targetId, comp, "ColorMap"); - if (typeof colorMap === "string" && colorMap.length > 0) { - return colorMap; - } - } - - return "batlow"; - }); - - async function applyCurrentRange(newValue: readonly [number, number]): Promise { - const targetId = dataIdRef.value; - if (targetId === undefined || targetId === "") { - return; - } - - const written = await Promise.all( - getActiveComponents(targetId).map(async (comp) => { - const componentWritten = await writeStyle( - targetId, - comp, - "Range", - newValue[0], - newValue[1], - ); - return componentWritten; - }), - ); - if (written.includes(true)) { - await hybridViewerStore.remoteRender(); - } - } - - const currentRange = computed({ - get(): readonly [number, number] { - const targetId = dataIdRef.value; - if (targetId === undefined || targetId === "") { - return [0, 1]; - } - - for (const comp of getActiveComponents(targetId)) { - const rawRange = readStyle(targetId, comp, "Range"); - const range = isNumberArray(rawRange) ? rawRange : undefined; - if (range && range.length === 2) { - return [range[0] ?? 0, range[1] ?? 1]; - } - } - return [0, 1]; - }, - // Vue's WritableComputedOptions setter is strictly `(v: T) => void`, so the async render call is delegated to a helper (see the same trade-off in `app/composables/hover_highlight.ts`'s `onHoverEnter`). - set: (newValue: readonly [number, number]): void => { - /* oxlint-disable-next-line promise/prefer-await-to-then -- setter cannot be async; see comment above. */ - applyCurrentRange(newValue).catch(() => undefined); - }, - }); - - async function applyGlobalColormap(newMap: string): Promise { - const targetId = dataIdRef.value; - if (targetId === undefined || targetId === "") { - return; - } - - await Promise.all( - getActiveComponents(targetId).map(async (comp) => { - await writeStyle(targetId, comp, "ColorMap", newMap); - }), - ); - await hybridViewerStore.remoteRender(); - } - - async function resetGlobalRange(): Promise { - const targetId = dataIdRef.value; - if (targetId === undefined || targetId === "") { - return; - } - - const requestPromises: Promise[] = []; - - for (const comp of getActiveComponents(targetId)) { - const rawAttrName = readStyle(targetId, comp, "Name"); - const attrName = isStringOrUndefined(rawAttrName) ? rawAttrName : undefined; - const rawAttrItem = readStyle(targetId, comp, "Item"); - const attrItem = (isNumberOrUndefined(rawAttrItem) ? rawAttrItem : undefined) ?? 0; - - if (attrName === undefined || attrName === "") { - continue; - } - - const rangeSchema = attributeSchema(comp, "range"); - if (!isJsonRpcSchema(rangeSchema)) { - continue; - } - requestPromises.push( - (async (): Promise => { - const range = await fetchAttributeRange( - rangeSchema, - attributeParams(targetId, comp), - attrName, - ); - if (!range) { - return; - } - const { min, max } = getAttributeRange(range, attrItem); - if (await writeStyle(targetId, comp, "Range", min, max)) { - await hybridViewerStore.remoteRender(); - } - })(), - ); - } - - await Promise.all(requestPromises); - } - - const currentTimeSteps = ref([]); - const temporalComponent = shallowRef(undefined); - - async function requestTimeSteps(targetId: string, comp: ActiveComponent): Promise { - const rawAttrName = readStyle(targetId, comp, "Name"); - const schema = attributeSchema(comp, "names"); - if ( - !isStringOrUndefined(rawAttrName) || - rawAttrName === undefined || - rawAttrName === "" || - !isJsonRpcSchema(schema) - ) { - return []; - } - let timeSteps: number[] = []; - await backStore.request( - { schema, params: attributeParams(targetId, comp) }, - { - response_function: (response: unknown): void => { - if (!isAttributeResponse(response)) { - return; - } - const found = response.attributes?.find((attr) => attr.attribute_name === rawAttrName); - timeSteps = [...(found?.time_steps ?? [])]; - }, + function meshColoring( + dataId: string, + { storeName }: MeshElement, + activeColoring: string, + ): AttributeColoring { + const methodName = `${storeName}${attributeType(activeColoring)}`; + const getterName = `${methodName.charAt(0).toLowerCase()}${methodName.slice(1)}`; + return { + read: (property): unknown => storeMethod(dataStyleStore, `${getterName}${property}`)(dataId), + write: async (property, ...values): Promise => { + await storeMethod(dataStyleStore, `set${methodName}${property}`)(dataId, ...values); }, - ); - return timeSteps; - } - - async function loadTimeSteps(): Promise { - const targetId = dataIdRef.value; - temporalComponent.value = undefined; - currentTimeSteps.value = []; - if (targetId === undefined || targetId === "") { - return; - } - for (const comp of getActiveComponents(targetId)) { - // oxlint-disable-next-line no-await-in-loop -- first temporal component wins, in order. - const timeSteps = await requestTimeSteps(targetId, comp); - if (timeSteps.length > 0) { - temporalComponent.value = comp; - currentTimeSteps.value = timeSteps; - return; - } - } + schemas: attributeSchemas("", activeColoring), + params: { id: dataId }, + }; } - watch( - [dataIdRef, componentViewerIdsRef], - async () => { - await resolveModelComponents(); - await loadTimeSteps(); - }, - { immediate: true }, - ); - - async function applyCurrentTimeStep(step: number): Promise { - const targetId = dataIdRef.value; - const comp = temporalComponent.value; - if (targetId === undefined || targetId === "" || !comp) { - return; - } - if (await writeStyle(targetId, comp, "TimeStep", step)) { - await hybridViewerStore.remoteRender(); + const colorings = computed(() => { + const dataId = dataIdRef.value; + // The picker exists before any scalar bar is picked + if (dataId === undefined) { + return []; } - } - - const currentTimeStep = computed({ - get(): number { - const targetId = dataIdRef.value; - const comp = temporalComponent.value; - if (targetId === undefined || targetId === "" || !comp) { - return 0; - } - const rawStep = readStyle(targetId, comp, "TimeStep"); - return typeof rawStep === "number" ? rawStep : 0; - }, - // Setter cannot be async (see `currentRange`), so the work is delegated to a helper. - set: (step: number): void => { - /* oxlint-disable-next-line promise/prefer-await-to-then -- setter cannot be async; see comment above. */ - applyCurrentTimeStep(step).catch(() => undefined); - }, + const style = dataStyleStore.getStyle(dataId); + return MESH_ELEMENTS.flatMap((element) => { + const coloring: unknown = style[element.styleKey]?.coloring; + const activeColoring = isColoring(coloring) ? coloring.active : undefined; + return activeColoring !== undefined && ATTRIBUTE_COLORINGS.has(activeColoring) + ? [meshColoring(dataId, element, activeColoring)] + : []; + }); }); - return { - currentColormap, - currentRange, - applyGlobalColormap, - resetGlobalRange, - currentTimeSteps, - currentTimeStep, - loadTimeSteps, - }; + const style = useAttributeColoringStyle(colorings); + watch(dataIdRef, style.loadTimeSteps, { immediate: true }); + return style; } diff --git a/app/composables/model_attribute_style.ts b/app/composables/model_attribute_style.ts new file mode 100644 index 00000000..30100610 --- /dev/null +++ b/app/composables/model_attribute_style.ts @@ -0,0 +1,79 @@ +import { + type AttributeColoring, + type AttributeColoringStyle, + attributeSchemas, + attributeType, + storeMethod, + useAttributeColoringStyle, +} from "@ogw_front/composables/attribute_coloring_style"; +import { useDataStore } from "@ogw_front/stores/data"; +import { useDataStyleStore } from "@ogw_front/stores/data_style"; + +// Components of the same type colored by the same attribute share the store methods, e.g. "modelBlocksPolyhedronAttributeName" +interface ComponentGroup { + // Component type of the store methods, e.g. "Block" + type: string; + activeColoring: string; + componentIds: string[]; +} + +function modelColoring( + dataStyleStore: object, + modelId: string, + { type, activeColoring, componentIds }: ComponentGroup, +): AttributeColoring { + const methodName = `${type}s${attributeType(activeColoring)}`; + return { + // Every component of the coloring shares its style: reading the first one is enough + read: (property): unknown => + storeMethod(dataStyleStore, `model${methodName}${property}`)(modelId, componentIds[0]), + write: async (property, ...values): Promise => { + await storeMethod(dataStyleStore, `setModel${methodName}${property}`)( + modelId, + componentIds, + ...values, + ); + }, + schemas: attributeSchemas("model_component_", activeColoring), + params: { id: modelId, component_ids: componentIds }, + }; +} + +// Styles the model components colored by the picked scalar bar +export function useModelAttributeStyle( + modelIdRef: Ref, + blockViewerIdsRef: Ref, +): AttributeColoringStyle { + const dataStyleStore = useDataStyleStore(); + const dataStore = useDataStore(); + + const colorings = shallowRef([]); + const style = useAttributeColoringStyle(colorings); + + async function loadColorings(): Promise { + const modelId = modelIdRef.value; + const blockViewerIds = blockViewerIdsRef.value; + const components = await dataStore.getComponentsByViewerIds(modelId, blockViewerIds); + // A newer pick replaced this one while the components were read + if (modelId !== modelIdRef.value || blockViewerIds !== blockViewerIdsRef.value) { + return; + } + const groups = new Map(); + for (const { type, geode_id } of components) { + const activeColoring = String( + storeMethod(dataStyleStore, `model${type}ActiveColoring`)(modelId, geode_id), + ); + const key = `${type}_${activeColoring}`; + const group = groups.get(key) ?? { type, activeColoring, componentIds: [] }; + group.componentIds.push(geode_id); + groups.set(key, group); + } + colorings.value = [...groups.values()].map((group) => + modelColoring(dataStyleStore, modelId, group), + ); + await style.loadTimeSteps(); + } + + watch([modelIdRef, blockViewerIdsRef], loadColorings, { immediate: true }); + return style; +} diff --git a/app/stores/data.ts b/app/stores/data.ts index cd2058ec..68481bf2 100644 --- a/app/stores/data.ts +++ b/app/stores/data.ts @@ -285,6 +285,17 @@ export const useDataStore = defineStore("data", () => { .first(); return component; } + async function getComponentsByViewerIds( + modelId: string, + viewer_ids: number[], + ): Promise { + const components = await model_components_db + .where("viewer_id") + .anyOf(viewer_ids) + .and((model_component: ModelComponentRecord) => model_component.id === modelId) + .toArray(); + return components; + } async function deleteModelComponents(modelId: string): Promise { await model_components_db.where("id").equals(modelId).delete(); await model_components_relation_db.where("id").equals(modelId).delete(); @@ -365,6 +376,7 @@ export const useDataStore = defineStore("data", () => { getAllModelComponentsViewerIds, getMeshComponentsViewerIds, getComponentByViewerId, + getComponentsByViewerIds, exportStores, importStores, clear, diff --git a/app/utils/global_attribute_components.ts b/app/utils/global_attribute_components.ts deleted file mode 100644 index 482b5574..00000000 --- a/app/utils/global_attribute_components.ts +++ /dev/null @@ -1,112 +0,0 @@ -import back_schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schemas.js"; - -interface ComponentNameEntry { - getterKey: string; - setterKey: string; - key: string; -} - -interface ActiveComponent extends ComponentNameEntry { - activeColoring: string; - attributeType: string; - // Geode ids of the model components sharing this coloring, undefined for a mesh - componentIds?: string[]; -} - -interface ModelComponent { - type: string; - geodeId: string; -} - -const ATTRIBUTE_COLORINGS = new Set(["vertex", "edge", "polygon", "cell", "polyhedron"]); - -function attributeTypeOf(activeColoring: string): string { - return `${activeColoring.charAt(0).toUpperCase()}${activeColoring.slice(1)}Attribute`; -} - -const MESH_COMPONENT_NAMES: ComponentNameEntry[] = [ - { getterKey: "meshPoints", setterKey: "MeshPoints", key: "points" }, - { getterKey: "meshEdges", setterKey: "MeshEdges", key: "edges" }, - { getterKey: "meshPolygons", setterKey: "MeshPolygons", key: "polygons" }, - { getterKey: "meshCells", setterKey: "MeshCells", key: "cells" }, - { getterKey: "meshPolyhedra", setterKey: "MeshPolyhedra", key: "polyhedra" }, -]; - -interface MeshStyle { - points?: Record; - edges?: Record; - polygons?: Record; - cells?: Record; - polyhedra?: Record; -} - -function isColoringValue(value: unknown): value is { active?: string } { - return typeof value === "object" && value !== null; -} - -function meshComponentStyle(style: MeshStyle, key: string): Record | undefined { - switch (key) { - case "points": { - return style.points; - } - case "edges": { - return style.edges; - } - case "polygons": { - return style.polygons; - } - case "cells": { - return style.cells; - } - case "polyhedra": { - return style.polyhedra; - } - default: { - return undefined; - } - } -} - -// The mesh element types colored by an attribute, from the mesh style -function meshActiveComponents(style: MeshStyle): ActiveComponent[] { - const activeComponents: ActiveComponent[] = []; - for (const { key, getterKey, setterKey } of MESH_COMPONENT_NAMES) { - const coloring = meshComponentStyle(style, key)?.coloring; - if (!isColoringValue(coloring)) { - continue; - } - const { active: activeColoring } = coloring; - if (activeColoring === undefined || !ATTRIBUTE_COLORINGS.has(activeColoring)) { - continue; - } - const attributeType = attributeTypeOf(activeColoring); - activeComponents.push({ activeColoring, attributeType, getterKey, setterKey, key }); - } - return activeComponents; -} - -// The *_attribute_names or *_attribute_range schema of the component coloring -function attributeSchema(comp: ActiveComponent, route: "names" | "range"): unknown { - const prefix = comp.componentIds ? "model_component_" : ""; - return (back_schemas.opengeodeweb_back as Record)[ - `${prefix}${comp.activeColoring}_attribute_${route}` - ]; -} - -function attributeParams( - targetId: string, - comp: ActiveComponent, -): { id: string; component_ids?: string[] } { - return comp.componentIds ? { id: targetId, component_ids: comp.componentIds } : { id: targetId }; -} - -export { - ATTRIBUTE_COLORINGS, - type ActiveComponent, - type ComponentNameEntry, - type ModelComponent, - attributeParams, - attributeSchema, - attributeTypeOf, - meshActiveComponents, -}; diff --git a/tests/unit/composables/global_attribute_style.nuxt.test.ts b/tests/unit/composables/model_attribute_style.nuxt.test.ts similarity index 73% rename from tests/unit/composables/global_attribute_style.nuxt.test.ts rename to tests/unit/composables/model_attribute_style.nuxt.test.ts index bc9723c9..e9a7e856 100644 --- a/tests/unit/composables/global_attribute_style.nuxt.test.ts +++ b/tests/unit/composables/model_attribute_style.nuxt.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import { flushPromises } from "@vue/test-utils"; // Local imports -import { useGlobalAttributeStyle } from "@ogw_front/composables/global_attribute_style"; +import { useModelAttributeStyle } from "@ogw_front/composables/model_attribute_style"; const MODEL_ID = "model"; const BLOCK_VIEWER_ID = 48; @@ -15,28 +15,30 @@ const remoteRender = vi.fn<() => undefined>(); vi.mock(import("@ogw_front/stores/data_style") as Promise, () => ({ useDataStyleStore: (): Record => ({ modelBlockActiveColoring: (): string => "polyhedron", + modelBlocksPolyhedronAttributeName: (): string => "pressure", modelBlocksPolyhedronAttributeColorMap: (): string => "batlow", setModelBlocksPolyhedronAttributeColorMap: setColorMap, }), })); vi.mock(import("@ogw_front/stores/data") as Promise, () => ({ useDataStore: (): Record => ({ - getComponentByViewerId: (): { type: string; geode_id: string } => ({ - type: "Block", - geode_id: BLOCK_GEODE_ID, - }), + getComponentsByViewerIds: (): { type: string; geode_id: string }[] => [ + { type: "Block", geode_id: BLOCK_GEODE_ID }, + ], }), })); vi.mock(import("@ogw_front/stores/hybrid_viewer") as Promise, () => ({ useHybridViewerStore: (): Record => ({ remoteRender }), })); vi.mock(import("@ogw_front/stores/back") as Promise, () => ({ - useBackStore: (): Record => ({ request: vi.fn<() => undefined>() }), + useBackStore: (): Record => ({ + request: vi.fn<() => { attributes: [] }>().mockReturnValue({ attributes: [] }), + }), })); -describe("global attribute style", () => { +describe("model attribute style", () => { test("applies the colormap to the model blocks of the picked scalar bar", async () => { - const { currentColormap, applyGlobalColormap } = useGlobalAttributeStyle( + const { currentColormap, applyGlobalColormap } = useModelAttributeStyle( ref(MODEL_ID), ref([BLOCK_VIEWER_ID]), ); From 13676b9de4cee893d014f01739371a2d4814106f Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 20 Oct 2026 14:31:24 +0200 Subject: [PATCH 4/6] fix(attributeRange): let back errors propagate from fetchAttributeRange A failed range request is removed from the cache and rethrown, so the next call retries it and callers no longer check for an undefined range. Co-Authored-By: Claude Opus 5.5 --- app/composables/global_attribute_style.ts | 3 -- app/utils/attribute_range.ts | 30 +++++++++---------- tests/unit/utils/attribute_range.nuxt.test.ts | 23 ++++++++++++-- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/app/composables/global_attribute_style.ts b/app/composables/global_attribute_style.ts index 3a4bf80e..b05bb966 100644 --- a/app/composables/global_attribute_style.ts +++ b/app/composables/global_attribute_style.ts @@ -266,9 +266,6 @@ export function useGlobalAttributeStyle(dataIdRef: Ref): { requestPromises.push( (async (): Promise => { const range = await fetchAttributeRange(rangeSchema, { id: targetId }, attrName); - if (!range) { - return; - } const { min, max } = getAttributeRange(range, attrItem); const setter = getDynamicStoreMethod( dataStyleStore, diff --git a/app/utils/attribute_range.ts b/app/utils/attribute_range.ts index cf0c6e62..ef144dba 100644 --- a/app/utils/attribute_range.ts +++ b/app/utils/attribute_range.ts @@ -19,38 +19,38 @@ interface AttributeRangeTarget { } // A data never changes once imported: selectors showing the same attribute share one request -const rangeRequests = new Map>(); +const rangeRequests = new Map>(); async function requestAttributeRange( rangeSchema: JsonRpcSchema, params: Record, - key: string, -): Promise { +): Promise { const backStore = useBackStore(); - try { - const range = await backStore.request({ schema: rangeSchema, params }); - // oxlint-disable-next-line no-unsafe-type-assertion -- every *_attribute_range schema shares this response. - return range as AttributeRangeInfo; - } catch { - rangeRequests.delete(key); - return undefined; - } + const range = await backStore.request({ schema: rangeSchema, params }); + // oxlint-disable-next-line no-unsafe-type-assertion -- every *_attribute_range schema shares this response. + return range as AttributeRangeInfo; } async function fetchAttributeRange( rangeSchema: JsonRpcSchema, target: AttributeRangeTarget, attributeName: string, -): Promise { +): Promise { const params = { ...target, attribute_name: attributeName }; const key = JSON.stringify([rangeSchema.$id, params]); let request = rangeRequests.get(key); if (request === undefined) { - request = requestAttributeRange(rangeSchema, params, key); + request = requestAttributeRange(rangeSchema, params); rangeRequests.set(key, request); } - const range = await request; - return range; + try { + const range = await request; + return range; + } catch (error) { + // A failed request is not kept: the next call retries it + rangeRequests.delete(key); + throw error; + } } export { type AttributeRangeInfo, type AttributeSchemas, fetchAttributeRange }; diff --git a/tests/unit/utils/attribute_range.nuxt.test.ts b/tests/unit/utils/attribute_range.nuxt.test.ts index 44a61128..2fecc90a 100644 --- a/tests/unit/utils/attribute_range.nuxt.test.ts +++ b/tests/unit/utils/attribute_range.nuxt.test.ts @@ -6,12 +6,18 @@ import back_schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schem import { fetchAttributeRange } from "@ogw_front/utils/attribute_range"; const RANGE = { min_values: [0], max_values: [1], no_data: false }; +const MISSING_ATTRIBUTE = "missing"; let nbRequests = 0; +type BackRequest = (request: { params: { attribute_name: string } }) => Promise; + vi.mock(import("@ogw_front/stores/back") as Promise, () => ({ - useBackStore: (): { request: () => Promise } => ({ - request: async (): Promise => { + useBackStore: (): { request: BackRequest } => ({ + request: async ({ params }): Promise => { nbRequests += 1; + if (params.attribute_name === MISSING_ATTRIBUTE) { + throw new Error("Attribute not found"); + } const range = await Promise.resolve(RANGE); return range; }, @@ -30,4 +36,17 @@ describe("attribute range", () => { expect(ranges).toStrictEqual([RANGE, RANGE]); expect(nbRequests).toBe(1); }); + + test("rethrows a failed back request and retries it on the next call", async () => { + const schema = back_schemas.opengeodeweb_back.vertex_attribute_range; + const nbRequestsBefore = nbRequests; + await expect(fetchAttributeRange(schema, { id: "id" }, MISSING_ATTRIBUTE)).rejects.toThrow( + "Attribute not found", + ); + await expect(fetchAttributeRange(schema, { id: "id" }, MISSING_ATTRIBUTE)).rejects.toThrow( + "Attribute not found", + ); + + expect(nbRequests).toBe(nbRequestsBefore + 2); + }); }); From a03aed289b63e4536cdd905a6fe127b62b5d2be8 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 20 Oct 2026 15:45:48 +0200 Subject: [PATCH 5/6] trigger From 52ba42d4ad0010d339420a549eeac6c91e2afc2f Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Wed, 21 Oct 2026 15:57:24 +0200 Subject: [PATCH 6/6] trigger