Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions packages/phoenix-event-display/src/managers/state-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -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'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions packages/phoenix-event-display/src/managers/url-options-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const phoenixURLOptions = {
file: '',
type: '',
config: '',
state: '',
hideWidgets: false,
embed: false,
};
Expand Down Expand Up @@ -108,7 +109,10 @@ export class URLOptionsManager {
})
.finally(() => {
this.eventDisplay.getLoadingManager().itemLoaded('url_config');
this.applyViewStateOption();
});
} else {
this.applyViewStateOption();
}
};

Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ <h5>URL options</h5>
Hide all widgets
</mat-checkbox>
</div>
<div class="form-group">
<mat-checkbox (change)="toggleViewState($event.checked)">
Include current view state (camera, visibility, clipping)
</mat-checkbox>
</div>

<h5>Share</h5>
<div class="share-box my-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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],
Expand All @@ -21,6 +31,10 @@ describe('ShareLinkDialogComponent', () => {
provide: MatDialogRef,
useValue: mockDialogRef,
},
{
provide: EventDisplayService,
useValue: mockEventDisplay,
},
],
declarations: [ShareLinkDialogComponent],
}).compileComponents();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,7 +24,10 @@ export class ShareLinkDialogComponent implements AfterViewInit, OnDestroy {
@ViewChild('qrcodeCanvas') qrcodeCanvas: ElementRef<HTMLCanvasElement>;
private unsubscribe: () => void;

constructor(private dialogRef: MatDialogRef<ShareLinkDialogComponent>) {
constructor(
private dialogRef: MatDialogRef<ShareLinkDialogComponent>,
private eventDisplay: EventDisplayService,
) {
const locationHref = window.location.href;
const lastIndex =
locationHref.lastIndexOf('?') === -1
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down