diff --git a/packages/phoenix-event-display/src/managers/state-manager.ts b/packages/phoenix-event-display/src/managers/state-manager.ts index cc037e083..7a086cae9 100644 --- a/packages/phoenix-event-display/src/managers/state-manager.ts +++ b/packages/phoenix-event-display/src/managers/state-manager.ts @@ -77,13 +77,20 @@ export class StateManager { } /** - * Save the state of the event display as JSON. + * Get the current state of the event display as a JSON object. + * @returns The state object with menu, camera, and clipping data. */ - saveStateAsJSON() { - const state: { [key: string]: any } = { - phoenixMenu: this.phoenixMenuRoot.getNodeState(), + getStateAsJSON(): { [key: string]: any } { + const controls = this.eventDisplay + ?.getThreeManager() + ?.getControlsManager() + ?.getMainControls(); + + return { + phoenixMenu: this.phoenixMenuRoot?.getNodeState(), eventDisplay: { - cameraPosition: this.activeCamera.position.toArray(), + cameraPosition: this.activeCamera?.position.toArray(), + cameraTarget: controls?.target?.toArray(), startClippingAngle: this.clippingEnabled.value ? this.startClippingAngle.value : null, @@ -92,6 +99,13 @@ export class StateManager { : null, }, }; + } + + /** + * Save the state of the event display as JSON. + */ + saveStateAsJSON() { + const state = this.getStateAsJSON(); saveFile( JSON.stringify(state), @@ -115,9 +129,22 @@ export class StateManager { if (jsonData['eventDisplay']) { console.log('StateManager: Processing eventDisplay configuration'); - this.activeCamera.position.fromArray( - jsonData['eventDisplay']?.['cameraPosition'], - ); + if (jsonData['eventDisplay']?.['cameraPosition']) { + this.activeCamera.position.fromArray( + jsonData['eventDisplay']['cameraPosition'], + ); + } + + if (jsonData['eventDisplay']?.['cameraTarget']) { + const controls = this.eventDisplay + ?.getThreeManager() + ?.getControlsManager() + ?.getMainControls(); + if (controls) { + controls.target.fromArray(jsonData['eventDisplay']['cameraTarget']); + controls.update(); + } + } const startAngle = jsonData['eventDisplay']?.['startClippingAngle']; const openingAngle = jsonData['eventDisplay']?.['openingClippingAngle']; diff --git a/packages/phoenix-event-display/src/managers/three-manager/index.ts b/packages/phoenix-event-display/src/managers/three-manager/index.ts index f587a872a..49bc6c4d1 100644 --- a/packages/phoenix-event-display/src/managers/three-manager/index.ts +++ b/packages/phoenix-event-display/src/managers/three-manager/index.ts @@ -319,6 +319,14 @@ export class ThreeManager { return this.sceneManager; } + /** + * Get the controls manager for accessing camera controls. + * @returns The controls manager. + */ + public getControlsManager(): ControlsManager { + return this.controlsManager; + } + /** * Sets controls to auto rotate. * @param autoRotate If the controls are to be automatically rotated or not. diff --git a/packages/phoenix-event-display/src/managers/url-options-manager.ts b/packages/phoenix-event-display/src/managers/url-options-manager.ts index 74cb7e472..e3c6995da 100644 --- a/packages/phoenix-event-display/src/managers/url-options-manager.ts +++ b/packages/phoenix-event-display/src/managers/url-options-manager.ts @@ -12,6 +12,7 @@ export const phoenixURLOptions = { file: '', type: '', config: '', + state: '', hideWidgets: false, embed: false, }; @@ -108,7 +109,10 @@ export class URLOptionsManager { }) .finally(() => { this.eventDisplay.getLoadingManager().itemLoaded('url_config'); + this.applyViewStateOption(); }); + } else { + this.applyViewStateOption(); } }; @@ -224,6 +228,42 @@ export class URLOptionsManager { this.eventDisplay.parsePhoenixEvents(allEventsObject); } + /** + * Apply view state from the URL's "state" parameter. + * Decodes a Base64-encoded JSON state and restores camera, clipping, and menu visibility. + * Uses a load listener to ensure state applies after all other initialization completes. + */ + private applyViewStateOption() { + const stateParam = this.urlOptions.get('state'); + if (!stateParam) { + return; + } + + const applyState = async () => { + try { + const binary = atob(stateParam); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + // Decompress the deflate-compressed state + const stream = new Blob([bytes]) + .stream() + .pipeThrough(new DecompressionStream('deflate')); + const decompressed = await new Response(stream).arrayBuffer(); + const jsonString = new TextDecoder().decode(decompressed); + const jsonState = JSON.parse(jsonString); + console.log('Applying view state from URL'); + const stateManager = new StateManager(); + stateManager.loadStateFromJSON(jsonState); + } catch (error) { + console.error('Could not parse view state from URL.', error); + } + }; + + this.eventDisplay.getLoadingManager().addLoadListenerWithCheck(() => { + // Small delay to ensure experiment component's load listener runs first + setTimeout(applyState, 200); + }); + } + /** * Hide all overlay widgets if "hideWidgets" option from the URL is true. */ diff --git a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/atlas/atlas.component.ts b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/atlas/atlas.component.ts index 60f730750..d6c6152c1 100644 --- a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/atlas/atlas.component.ts +++ b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/atlas/atlas.component.ts @@ -272,12 +272,12 @@ export class AtlasComponent implements OnInit, OnDestroy { console.log('Loading default configuration.'); this.loaded = true; - const urlConfig = this.eventDisplay - .getURLOptionsManager() - .getURLOptions() - .get('config'); + const urlOptionsManager = this.eventDisplay.getURLOptionsManager(); + const urlConfig = urlOptionsManager.getURLOptions().get('config'); + const urlState = urlOptionsManager.getURLOptions().get('state'); - if (!urlConfig) { + // Skip default config if a full view state is provided via URL + if (!urlConfig && !urlState) { const stateManager = new StateManager(); stateManager.loadStateFromJSON(phoenixMenuConfig); } diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.html index 2021fd363..2ef2d7796 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.html +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.html @@ -44,6 +44,11 @@
URL options
Hide all widgets +
+ + Include current view state (camera, visibility, clipping) + +
Share
diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.test.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.test.ts index 7bd1a6cc4..86882a584 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.test.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialogRef } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { PhoenixUIModule } from '../../../phoenix-ui.module'; +import { EventDisplayService } from '../../../../services/event-display.service'; import { ShareLinkDialogComponent } from './share-link-dialog.component'; @@ -13,6 +14,15 @@ describe('ShareLinkDialogComponent', () => { close: jest.fn(), }; + const mockEventDisplay = { + getStateManager: jest.fn().mockReturnValue({ + getStateAsJSON: jest.fn().mockReturnValue({ + phoenixMenu: {}, + eventDisplay: { cameraPosition: [0, 0, 0] }, + }), + }), + }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [BrowserAnimationsModule, PhoenixUIModule], @@ -21,6 +31,10 @@ describe('ShareLinkDialogComponent', () => { provide: MatDialogRef, useValue: mockDialogRef, }, + { + provide: EventDisplayService, + useValue: mockEventDisplay, + }, ], declarations: [ShareLinkDialogComponent], }).compileComponents(); diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.ts index 443b1ebaf..0ad169e0f 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/share-link/share-link-dialog/share-link-dialog.component.ts @@ -8,6 +8,7 @@ import { import { MatDialogRef } from '@angular/material/dialog'; import { ActiveVariable, phoenixURLOptions } from 'phoenix-event-display'; import QRCode from 'qrcode'; +import { EventDisplayService } from '../../../../services/event-display.service'; @Component({ standalone: false, @@ -23,7 +24,10 @@ export class ShareLinkDialogComponent implements AfterViewInit, OnDestroy { @ViewChild('qrcodeCanvas') qrcodeCanvas: ElementRef; private unsubscribe: () => void; - constructor(private dialogRef: MatDialogRef) { + constructor( + private dialogRef: MatDialogRef, + private eventDisplay: EventDisplayService, + ) { const locationHref = window.location.href; const lastIndex = locationHref.lastIndexOf('?') === -1 @@ -64,7 +68,7 @@ export class ShareLinkDialogComponent implements AfterViewInit, OnDestroy { .reduce((filteredOptions: string[], option: string) => { if (this.urlOptions[option]) { filteredOptions.push( - `${option}=${encodeURI(this.urlOptions[option])}`, + `${option}=${encodeURIComponent(this.urlOptions[option])}`, ); } return filteredOptions; @@ -77,6 +81,29 @@ export class ShareLinkDialogComponent implements AfterViewInit, OnDestroy { this.embedLink = this.getEmbedLink(urlParametersString); } + async toggleViewState(include: boolean) { + if (include) { + const stateManager = this.eventDisplay.getStateManager(); + if (stateManager) { + const state = stateManager.getStateAsJSON(); + const jsonStr = JSON.stringify(state); + // Compress using built-in deflate to keep URL manageable + const stream = new Blob([jsonStr]) + .stream() + .pipeThrough(new CompressionStream('deflate')); + const compressed = await new Response(stream).arrayBuffer(); + const bytes = new Uint8Array(compressed); + const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join( + '', + ); + this.urlOptions['state'] = btoa(binary); + } + } else { + this.urlOptions['state'] = ''; + } + this.onOptionsChange(); + } + copyText(text: string, element: HTMLElement) { const inputElement = document.createElement('input'); document.body.appendChild(inputElement);