diff --git a/Artifacts/clientdist/_githash_client_svelte.txt b/Artifacts/clientdist/_githash_client_svelte.txt index 092ddc6a..49f617da 100644 --- a/Artifacts/clientdist/_githash_client_svelte.txt +++ b/Artifacts/clientdist/_githash_client_svelte.txt @@ -1 +1 @@ -85ddfe2c670ad04dca6f43142948e811d198b2e5 +2f7ea1549da4da7805aa990cac54f239a26eb51b diff --git a/Artifacts/clientdist/clientpackage_svelte.zip b/Artifacts/clientdist/clientpackage_svelte.zip index 68633463..35d52a9c 100644 Binary files a/Artifacts/clientdist/clientpackage_svelte.zip and b/Artifacts/clientdist/clientpackage_svelte.zip differ diff --git a/BuildScripts/createPackageXML.cpp b/BuildScripts/createPackageXML.cpp index 9f85550d..f8f1c329 100644 --- a/BuildScripts/createPackageXML.cpp +++ b/BuildScripts/createPackageXML.cpp @@ -338,7 +338,7 @@ int main(int argc, char* argv[]) std::stringstream serverXMLStream; serverXMLStream << "\n"; - serverXMLStream << "\n"; + serverXMLStream << "\n"; serverXMLStream << " \n"; serverXMLStream << " \n"; serverXMLStream << " \n"; diff --git a/Client/core/common/AMCApplication.js b/Client/core/common/AMCApplication.js index 13413fe3..d9a7bf41 100644 --- a/Client/core/common/AMCApplication.js +++ b/Client/core/common/AMCApplication.js @@ -81,6 +81,7 @@ import AMCApplicationDialog from "./AMCDialog.js" const CONFIG_REQUEST_TIMEOUT_MS = 2000; const DEFAULT_REQUEST_TIMEOUT_MS = 10000; +const EVENT_ERROR_SNACKBAR_TIMEOUT_MS = 8000; const MAX_CONSECUTIVE_FAILURES = 5; export default class AMCApplication extends Common.AMCObject { @@ -157,7 +158,10 @@ export default class AMCApplication extends Common.AMCObject { Timeout: -1, Text: "", Color: "secondary", - FontColor: "white" + FontColor: "white", + // Incremented on every showSnackBar () call, so the client can restart its auto-hide + // timer even if text and visibility have not changed. + Sequence: 0 } } @@ -920,6 +924,20 @@ export default class AMCApplication extends Common.AMCObject { } } } + + // Dialog content is indexed too, so synced items inside a dialog refresh + // from the same payload instead of falling back to legacy polling. + if (resultJSON.data && resultJSON.data.dialogs) { + for (let dialog of resultJSON.data.dialogs) { + if (dialog.modules) { + for (let mod of dialog.modules) { + this._indexFrontendModule(mod); + } + } + } + + this._syncServerDrivenDialogs(resultJSON.data.dialogs); + } }) .catch(err => { this.API.unsuccessfulFrontendCounter = (this.API.unsuccessfulFrontendCounter || 0) + 1; @@ -1010,6 +1028,37 @@ export default class AMCApplication extends Common.AMCObject { return true; } + // Applies the server-side open state of dialogs that carry an "active" flag (dialogs + // with sync:active in the machine configuration). The server is authoritative: such a + // dialog is opened while the flag is true, even if a client action closed it in between, + // and closed as soon as the flag turns false. Dialogs without the flag are untouched. + _syncServerDrivenDialogs(dialogsJSON) { + let changed = false; + + for (let dialogJSON of dialogsJSON) { + if (typeof dialogJSON.active !== "boolean") + continue; + + let dialog = this.AppContent.DialogMap.get(dialogJSON.name); + if (!dialog) + continue; + + dialog.serverDriven = true; + dialog.closable = (dialogJSON.closable !== false); + + if (dialogJSON.active && !dialog.dialogIsActive && (dialog.visible !== false)) { + // showDialog already refreshes the content items. + this.showDialog(dialog.name); + } else if (!dialogJSON.active && dialog.dialogIsActive) { + dialog.dialogIsActive = false; + changed = true; + } + } + + if (changed) + this.updateContentItems(); + } + // Recursively index a v2 module (and its submodules) into frontendLookup by UUID. _indexFrontendModule(mod) { if (!mod || !mod.uuid) @@ -1487,6 +1536,9 @@ export default class AMCApplication extends Common.AMCObject { } } + // Only one dialog is open at a time. A server-driven dialog (see + // _syncServerDrivenDialogs) re-opens on the next frontend poll while its flag is + // set, so it wins over dialogs opened by client actions. showDialog(dialog) { this.closeAllDialogs(); @@ -1554,7 +1606,13 @@ export default class AMCApplication extends Common.AMCObject { this.axiosPostRequest("/event", requestBody) .then(resultHandleEvent => { - + + // A failed event carries no actions and must not run the success callback + if (resultHandleEvent.data.errorcode) { + this.showEventError(eventname, resultHandleEvent.data.errormessage); + return; + } + if (resultHandleEvent.data.actions) { if (Array.isArray(resultHandleEvent.data.actions)) { let action; @@ -1584,10 +1642,27 @@ export default class AMCApplication extends Common.AMCObject { }) .catch(err => { console.log(err); + this.showEventError(eventname, this.extractErrorMessage(err)); }); } - triggerWidgetRequest (widgetuuid, requestType, requestJSON, executionCallback) { + // Sets every snack bar field, so a caller never inherits the color or timeout of a previous message. + // timeout <= 0 keeps the message until it is dismissed. + showSnackBar(text, color, timeout) { + this.SnackBar.Text = text; + this.SnackBar.Color = color || "secondary"; + this.SnackBar.Timeout = (timeout > 0) ? timeout : -1; + this.SnackBar.Sequence++; + this.SnackBar.Visible = true; + } + + // Failed events were only visible in the browser console. Report them in the snack bar. + showEventError(eventname, errormessage) { + this.showSnackBar("Event \"" + eventname + "\" failed: " + errormessage, "error", EVENT_ERROR_SNACKBAR_TIMEOUT_MS); + } + + // failureCallback receives the request error, so the caller can roll back optimistic UI state. + triggerWidgetRequest (widgetuuid, requestType, requestJSON, executionCallback, failureCallback) { this.axiosPostRequest("/widget/" + Assert.UUIDValue (widgetuuid) + "/" + Assert.StringValue (requestType), Assert.ObjectValue (requestJSON)) @@ -1617,11 +1692,14 @@ export default class AMCApplication extends Common.AMCObject { if (executionCallback) { executionCallback (); - } - + } + }) .catch(err => { console.log(err); + if (failureCallback) { + failureCallback (err); + } }); } diff --git a/Client/core/common/AMCDialog.js b/Client/core/common/AMCDialog.js index fdc62238..03ee82ad 100644 --- a/Client/core/common/AMCDialog.js +++ b/Client/core/common/AMCDialog.js @@ -41,6 +41,13 @@ export default class AMCApplicationDialog extends AMCApplicationPage { // dialog JSON); capture it so the client can render the dialog header. this.title = dialogJSON.title || ""; this.dialogIsActive = false; + + // "closable" (default true): a non-closable dialog has no close button and ignores + // Escape and outside clicks. "active" is only sent for server-driven dialogs; their + // open state follows the server on every frontend poll instead of client actions. + // Both are initialised here so Vue 2 tracks them. + this.closable = (dialogJSON.closable !== false); + this.serverDriven = (typeof dialogJSON.active === "boolean"); } // A dialog is never the application's active page, so the inherited page-based diff --git a/Client/core/common/AMCImplementation_WebGL.js b/Client/core/common/AMCImplementation_WebGL.js index 90176a90..ac8537e9 100644 --- a/Client/core/common/AMCImplementation_WebGL.js +++ b/Client/core/common/AMCImplementation_WebGL.js @@ -424,8 +424,6 @@ class WebGLLocalizedPointsElement extends WebGLElement { let localmouseX = (mouseX - this.position.x) / this.scale.x; let localmouseY = (mouseY - this.position.y) / this.scale.y; - console.log (mouseX + " " + mouseY + ": " + localmouseX + " " + localmouseY); - let quadx = Math.floor ( (localmouseX - this.originx) / this.quadsizex); let quady = Math.floor ( (localmouseY - this.originy) / this.quadsizey); diff --git a/Client/svelte/src/lib/amcf/MdiIcon.svelte b/Client/svelte/src/lib/amcf/MdiIcon.svelte index dc9e724b..d3dbb6ba 100644 --- a/Client/svelte/src/lib/amcf/MdiIcon.svelte +++ b/Client/svelte/src/lib/amcf/MdiIcon.svelte @@ -157,6 +157,7 @@ 'mdi-clock': Clock, 'mdi-clock-outline': Clock, 'mdi-timer': Clock, + 'mdi-timer-outline': Clock, 'mdi-calendar': Calendar, 'mdi-calendar-outline': Calendar, 'mdi-map': Map, diff --git a/Client/svelte/src/lib/amcf/Snackbar.svelte b/Client/svelte/src/lib/amcf/Snackbar.svelte index f0c36939..c9e2f44d 100644 --- a/Client/svelte/src/lib/amcf/Snackbar.svelte +++ b/Client/svelte/src/lib/amcf/Snackbar.svelte @@ -10,11 +10,14 @@ let color = $derived.by(() => { poll.v; return app?.SnackBar?.Color || 'secondary'; }); let fontColor = $derived.by(() => { poll.v; return app?.SnackBar?.FontColor || 'white'; }); let timeout = $derived.by(() => { poll.v; return app?.SnackBar?.Timeout || -1; }); + let sequence = $derived.by(() => { poll.v; return app?.SnackBar?.Sequence || 0; }); let show = $state(false); let autoHideTimer: ReturnType | null = null; $effect(() => { + // Depend on the sequence so that a repeated identical message restarts the auto-hide timer + sequence; if (visible && text) { show = true; if (autoHideTimer) clearTimeout(autoHideTimer); diff --git a/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte b/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte index b845bb13..daf09cf0 100644 --- a/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte +++ b/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte @@ -6,6 +6,8 @@ import Image from '@lucide/svelte/icons/image'; import { usePollTick } from '$lib/amcf/poll.svelte'; + // @ts-ignore — core JS has no type declarations yet + import { triggerButtonEvent } from '@core/modules/AMCModule_TableUtils.js'; let { module, app }: { module: any; app: any } = $props(); const poll = usePollTick(); @@ -40,15 +42,9 @@ } } + // Shared with the Vue 2 client: selectionvalueuuid receives the build, buttonvalueuuid the button function triggerButton (btn: any, build: any) { - if (btn.selectevent && app) { - const formvalues: Record = {}; - if (module.selectionvalueuuid) - formvalues[module.selectionvalueuuid] = build.buildUUID; - if (module.buttonvalueuuid) - formvalues[module.buttonvalueuuid] = build.buildUUID; - app.triggerUIEvent(btn.selectevent, module.uuid, formvalues); - } + triggerButtonEvent(app, module, btn, build.buildUUID); } diff --git a/Client/svelte/src/lib/amcf/modules/ModuleConfigurationList.svelte b/Client/svelte/src/lib/amcf/modules/ModuleConfigurationList.svelte index f5fb2fa5..0b3c796e 100644 --- a/Client/svelte/src/lib/amcf/modules/ModuleConfigurationList.svelte +++ b/Client/svelte/src/lib/amcf/modules/ModuleConfigurationList.svelte @@ -24,13 +24,16 @@ } catch { return ts; } } + // Used when the configuration list defines no + + {#if pointsAvailable} + + + {/if} @@ -718,7 +1037,7 @@ type="number" inputmode="numeric" min="0" - max={layerCount} + max={layerCount - 1} step="1" bind:value={layerJumpValue} onkeydown={onLayerJumpKeyDown} @@ -732,7 +1051,7 @@ type="button" class="layerview-layer-info layerview-layer-info-button" onclick={openLayerJump} - title="Click to jump to a layer" + title={sliderFixed ? 'Layer' : 'Click to jump to a layer'} aria-label={`Layer ${sliderValue} of ${layerCount}. Click to jump to a layer`} > Layer {sliderValue} / {layerCount} @@ -769,6 +1088,24 @@ {/if} + + {#if (labelVisible && (labelCaption || labelIcon)) || loadingLayer || loadingPoints} +
+ {#if labelVisible && (labelCaption || labelIcon)} +
+ + {labelCaption} +
+ {/if} + {#if loadingLayer || loadingPoints} +
+ + Loading layer data +
+ {/if} +
+ {/if} + {#if mousePosition}
@@ -776,6 +1113,14 @@
{/if} + + {#if hoverInfo.visible && !propertiesMode} +
{hoverInfo.text}
+ {/if} + {#if propertiesMode && hoverSegment}
{/if} - + {#if layerCount > 0}
+ {#if !sliderFixed} + + {/if} + {#if !sliderFixed} + + {/if}
{/if}
@@ -928,7 +1286,10 @@ position: absolute; top: 8px; left: 8px; + /* Leave room for the layer-info badge in the top-right corner */ + max-width: calc(100% - 140px); display: flex; + flex-wrap: wrap; gap: 4px; z-index: 10; } @@ -961,6 +1322,15 @@ background-color: var(--primary, #2563eb); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.65); } + .layerview-btn:disabled { + opacity: 0.4; + cursor: default; + } + .layerview-step { + width: 24px; + height: 24px; + padding: 0; + } .layerview-layer-info { position: absolute; top: 8px; @@ -1003,6 +1373,8 @@ bottom: 8px; width: 64px; height: 64px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.35); pointer-events: none; z-index: 9; } @@ -1029,6 +1401,31 @@ .coordinate-label-y { fill: #22c55e; } + .layerview-status { + position: absolute; + left: 8px; + bottom: 8px; + display: flex; + flex-wrap: wrap; + gap: 4px; + z-index: 10; + pointer-events: none; + } + .layerview-status.beside-axes { + /* Clear the coordinate axes indicator in the bottom-left corner */ + left: 80px; + } + .layerview-label { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.75); + color: white; + font-size: 11px; + font-variant-numeric: tabular-nums; + } .layerview-mouse-pos { position: absolute; /* Shifted left so it clears the vertical layer slider on the right edge. */ @@ -1043,6 +1440,18 @@ pointer-events: none; z-index: 10; } + .layerview-hover-info { + position: absolute; + z-index: 20; + padding: 5px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.75); + color: white; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: pre-line; + pointer-events: none; + } .layerview-segment-popup { position: absolute; min-width: 150px; @@ -1086,14 +1495,17 @@ top: 44px; bottom: 16px; display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 6px; z-index: 10; } .layerview-slider { writing-mode: vertical-lr; direction: rtl; - height: 100%; + flex: 1; + min-height: 0; width: 20px; accent-color: var(--primary, #2563eb); } diff --git a/Client/svelte/src/lib/components/ui/dialog/dialog-content.svelte b/Client/svelte/src/lib/components/ui/dialog/dialog-content.svelte index 5c6ee6db..27966c81 100644 --- a/Client/svelte/src/lib/components/ui/dialog/dialog-content.svelte +++ b/Client/svelte/src/lib/components/ui/dialog/dialog-content.svelte @@ -27,7 +27,7 @@ bind:ref data-slot="dialog-content" class={cn( - "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", + "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid grid-cols-[minmax(0,1fr)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 wrap-anywhere sm:max-w-lg", className )} {...restProps} diff --git a/Client/svelte/src/lib/components/ui/dialog/dialog-footer.svelte b/Client/svelte/src/lib/components/ui/dialog/dialog-footer.svelte index e7ff4468..e19ffa1b 100644 --- a/Client/svelte/src/lib/components/ui/dialog/dialog-footer.svelte +++ b/Client/svelte/src/lib/components/ui/dialog/dialog-footer.svelte @@ -13,7 +13,7 @@
{@render children?.()} diff --git a/Client/svelte/src/lib/components/ui/dialog/dialog-title.svelte b/Client/svelte/src/lib/components/ui/dialog/dialog-title.svelte index e4d4b344..62937330 100644 --- a/Client/svelte/src/lib/components/ui/dialog/dialog-title.svelte +++ b/Client/svelte/src/lib/components/ui/dialog/dialog-title.svelte @@ -12,6 +12,6 @@ diff --git a/Client/svelte/src/routes/+page.svelte b/Client/svelte/src/routes/+page.svelte index 8800d899..7208e024 100644 --- a/Client/svelte/src/routes/+page.svelte +++ b/Client/svelte/src/routes/+page.svelte @@ -266,10 +266,20 @@ if (open) { app.showDialog(dialog.name); } else { + // A non-closable dialog is only closed by the server (see + // AMCApplication._syncServerDrivenDialogs) or by a client action. + if (dialog.closable === false) return; dialog.dialogIsActive = false; bumpTick(); } } + + // Read through a poll-tick dependency: closable is a plain property mutation on a + // dialog object with stable identity, so it would not re-trigger on its own. + function dialogIsClosable (dialog: any): boolean { + poll.v; + return dialog.closable !== false; + } @@ -414,10 +424,10 @@ {/if} -
+
{#each pages as page (page.name)} {#if poll.v >= 0 && app.pageIsActive(page)} -
+
{#each page.modules || [] as mod (mod.uuid)} {/each} @@ -429,11 +439,17 @@ {#each dialogs as dialog (dialog.name)} + {@const closable = dialogIsClosable(dialog)} setDialogOpen(dialog, open)} > - + {#if dialog.title} {dialog.title} diff --git a/Client/vue2/src/App.vue b/Client/vue2/src/App.vue index c285f783..8c9a5099 100644 --- a/Client/vue2/src/App.vue +++ b/Client/vue2/src/App.vue @@ -109,11 +109,13 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.