From a3156f9d1540ddc4eb5f4679372cb203e435f156 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 23 Jun 2026 13:20:01 -0700 Subject: [PATCH 1/3] feat: pass view settings (antigravity) --- dist/components/ThreeDEditor.d.ts | 29 ++++- dist/components/ThreeDEditor.js | 61 ++++++++-- dist/exports.d.ts | 1 + dist/exports.js | 1 + dist/utils/viewSettingsUrl.d.ts | 39 +++++++ dist/utils/viewSettingsUrl.js | 147 +++++++++++++++++++++++ src/components/ThreeDEditor.jsx | 71 +++++++++-- src/exports.js | 4 + src/utils/viewSettingsUrl.ts | 188 ++++++++++++++++++++++++++++++ 9 files changed, 517 insertions(+), 24 deletions(-) create mode 100644 dist/utils/viewSettingsUrl.d.ts create mode 100644 dist/utils/viewSettingsUrl.js create mode 100644 src/utils/viewSettingsUrl.ts diff --git a/dist/components/ThreeDEditor.d.ts b/dist/components/ThreeDEditor.d.ts index 156c4f75..250f39d0 100644 --- a/dist/components/ThreeDEditor.d.ts +++ b/dist/components/ThreeDEditor.d.ts @@ -18,12 +18,20 @@ export class ThreeDEditor extends React.Component { }[]; viewerTriggerResize: boolean; viewerSettings: { - isViewAdjustable: boolean; - atomRadiiScale: number; - repetitionsAlongLatticeVectorA: number; - repetitionsAlongLatticeVectorB: number; - repetitionsAlongLatticeVectorC: number; - chemicalConnectivityFactor: number; + isViewAdjustable: any; + atomRadiiScale: any; + repetitionsAlongLatticeVectorA: any; + repetitionsAlongLatticeVectorB: any; + repetitionsAlongLatticeVectorC: any; + chemicalConnectivityFactor: any; + }; + _initialToggleSettings: { + orthographicCamera: any; + bonds: any; + axes: any; + autoRotate: any; + elementLabels: any; + coordinateLabels: any; }; boundaryConditions: any; isConventionalCellShown: any; @@ -60,6 +68,12 @@ export class ThreeDEditor extends React.Component { handleMessage: (event: any) => void; handleSetMaterial(newMaterialConfig: any): void; componentDidMount(): void; + /** + * Apply toggle-based view settings from URL params after the Wave instance is mounted. + * These settings are imperative (they toggle state on the Wave class instance), + * so they must be applied after componentDidMount when WaveComponent.wave exists. + */ + _applyInitialToggleSettings(): void; componentWillUnmount(): void; UNSAFE_componentWillReceiveProps(nextProps: any, nextContext: any): void; _resetStateWaveComponent(): void; @@ -237,6 +251,7 @@ export namespace ThreeDEditor { let boundaryConditions: PropTypes.Requireable; let onUpdate: PropTypes.Requireable<(...args: any[]) => any>; let isStandalone: PropTypes.Requireable; + let initialViewSettings: PropTypes.Requireable; } namespace defaultProps { let boundaryConditions_1: {}; @@ -249,6 +264,8 @@ export namespace ThreeDEditor { export { editable_1 as editable }; let isStandalone_1: boolean; export { isStandalone_1 as isStandalone }; + let initialViewSettings_1: {}; + export { initialViewSettings_1 as initialViewSettings }; } } import React from "react"; diff --git a/dist/components/ThreeDEditor.js b/dist/components/ThreeDEditor.js index f59c5b73..3af1c929 100644 --- a/dist/components/ThreeDEditor.js +++ b/dist/components/ThreeDEditor.js @@ -47,6 +47,7 @@ export class ThreeDEditor extends React.Component { * @param props Properties as explained below */ constructor(props) { + var _a, _b, _c, _d, _e, _f, _g; super(props); this.handleSetSetting = (setting) => { const { viewerSettings } = this.state; @@ -284,7 +285,7 @@ export class ThreeDEditor extends React.Component { const { viewerSettings } = this.state; return (_jsx(ParametersMenu, { viewerSettings: viewerSettings, handleSphereRadiusChange: this.handleSphereRadiusChange, handleCellRepetitionsChange: this.handleCellRepetitionsChange, handleChemicalConnectivityFactorChange: this.handleChemicalConnectivityFactorChange })); }; - const { boundaryConditions, isConventionalCellShown, material } = this.props; + const { boundaryConditions, isConventionalCellShown, material, initialViewSettings = {}, } = this.props; // TODO : overloading a bunch of props and state attributes here.. this.state = { // on/off switch for the component @@ -296,17 +297,26 @@ export class ThreeDEditor extends React.Component { // TODO: remove the need for `viewerTriggerResize` // whether to trigger resize viewerTriggerResize: false, - // Settings of the wave viewer + // Settings of the wave viewer, merged with any initial overrides from URL params viewerSettings: { - isViewAdjustable: settings.isViewAdjustable, - atomRadiiScale: settings.atomRadiiScale, - repetitionsAlongLatticeVectorA: settings.repetitions, - repetitionsAlongLatticeVectorB: settings.repetitions, - repetitionsAlongLatticeVectorC: settings.repetitions, - chemicalConnectivityFactor: settings.chemicalConnectivityFactor, + isViewAdjustable: (_a = initialViewSettings.isViewAdjustable) !== null && _a !== void 0 ? _a : settings.isViewAdjustable, + atomRadiiScale: (_b = initialViewSettings.atomRadiiScale) !== null && _b !== void 0 ? _b : settings.atomRadiiScale, + repetitionsAlongLatticeVectorA: (_c = initialViewSettings.repetitionsAlongLatticeVectorA) !== null && _c !== void 0 ? _c : settings.repetitions, + repetitionsAlongLatticeVectorB: (_d = initialViewSettings.repetitionsAlongLatticeVectorB) !== null && _d !== void 0 ? _d : settings.repetitions, + repetitionsAlongLatticeVectorC: (_e = initialViewSettings.repetitionsAlongLatticeVectorC) !== null && _e !== void 0 ? _e : settings.repetitions, + chemicalConnectivityFactor: (_f = initialViewSettings.chemicalConnectivityFactor) !== null && _f !== void 0 ? _f : settings.chemicalConnectivityFactor, + }, + // Toggle settings from URL to apply after Wave instance mounts + _initialToggleSettings: { + orthographicCamera: initialViewSettings.orthographicCamera, + bonds: initialViewSettings.bonds, + axes: initialViewSettings.axes, + autoRotate: initialViewSettings.autoRotate, + elementLabels: initialViewSettings.elementLabels, + coordinateLabels: initialViewSettings.coordinateLabels, }, boundaryConditions, - isConventionalCellShown, + isConventionalCellShown: (_g = initialViewSettings.conventionalCell) !== null && _g !== void 0 ? _g : isConventionalCellShown, // material that is originally passed to the component and can be modified in ThreejsEditorModal component. originalMaterial: material, // material that is passed to WaveComponent to be visualized and may have repetition and radius adjusted. @@ -348,6 +358,36 @@ export class ThreeDEditor extends React.Component { componentDidMount() { this.addHotKeyListener(); window.addEventListener("message", this.handleMessage); + this._applyInitialToggleSettings(); + } + /** + * Apply toggle-based view settings from URL params after the Wave instance is mounted. + * These settings are imperative (they toggle state on the Wave class instance), + * so they must be applied after componentDidMount when WaveComponent.wave exists. + */ + _applyInitialToggleSettings() { + var _a; + const { _initialToggleSettings } = this.state; + if (!_initialToggleSettings || !((_a = this.WaveComponent) === null || _a === void 0 ? void 0 : _a.wave)) + return; + if (_initialToggleSettings.orthographicCamera) { + this.handleToggleOrthographicCamera(); + } + if (_initialToggleSettings.bonds) { + this.handleToggleBonds(); + } + if (_initialToggleSettings.axes) { + this.handleToggleAxes(); + } + if (_initialToggleSettings.autoRotate) { + this.handleToggleOrbitControlsAnimation(); + } + if (_initialToggleSettings.elementLabels) { + this.handleToggleElementLabels(); + } + if (_initialToggleSettings.coordinateLabels) { + this.handleToggleCoordinateLabels(); + } } componentWillUnmount() { this.removeHotKeyListener(); @@ -649,6 +689,8 @@ ThreeDEditor.propTypes = { boundaryConditions: PropTypes.object, onUpdate: PropTypes.func, isStandalone: PropTypes.bool, + // eslint-disable-next-line react/forbid-prop-types + initialViewSettings: PropTypes.object, }; ThreeDEditor.defaultProps = { boundaryConditions: {}, @@ -656,4 +698,5 @@ ThreeDEditor.defaultProps = { onUpdate: undefined, editable: false, isStandalone: false, + initialViewSettings: {}, }; diff --git a/dist/exports.d.ts b/dist/exports.d.ts index e49bd377..9e401a3b 100644 --- a/dist/exports.d.ts +++ b/dist/exports.d.ts @@ -1,2 +1,3 @@ export { ThreeDEditor } from "./components/ThreeDEditor"; export { ThreejsEditorModal } from "./components/ThreejsEditorModal"; +export { parseViewSettingsFromUrlParams, serializeViewSettingsToUrlParams } from "./utils/viewSettingsUrl"; diff --git a/dist/exports.js b/dist/exports.js index e49bd377..84bec4d1 100644 --- a/dist/exports.js +++ b/dist/exports.js @@ -1,2 +1,3 @@ export { ThreeDEditor } from "./components/ThreeDEditor"; export { ThreejsEditorModal } from "./components/ThreejsEditorModal"; +export { parseViewSettingsFromUrlParams, serializeViewSettingsToUrlParams, } from "./utils/viewSettingsUrl"; diff --git a/dist/utils/viewSettingsUrl.d.ts b/dist/utils/viewSettingsUrl.d.ts new file mode 100644 index 00000000..dd898494 --- /dev/null +++ b/dist/utils/viewSettingsUrl.d.ts @@ -0,0 +1,39 @@ +/** + * View settings that can be passed via URL query parameters. + * Includes both numeric "viewerSettings" values and boolean toggle settings. + */ +export interface ViewSettingsFromUrl { + atomRadiiScale?: number; + repetitionsAlongLatticeVectorA?: number; + repetitionsAlongLatticeVectorB?: number; + repetitionsAlongLatticeVectorC?: number; + chemicalConnectivityFactor?: number; + isViewAdjustable?: boolean; + orthographicCamera?: boolean; + bonds?: boolean; + axes?: boolean; + autoRotate?: boolean; + elementLabels?: boolean; + coordinateLabels?: boolean; + conventionalCell?: boolean; +} +/** + * Parse URL query parameters into a ViewSettingsFromUrl object. + * Unknown or invalid parameters are silently ignored. + * + * The `repetitions` param supports two formats: + * - Single number (e.g. `repetitions=2`) → applied to all three axes + * - Comma-separated (e.g. `repetitions=2,3,1`) → A=2, B=3, C=1 + * + * Accepts values as strings (from URLSearchParams) or pre-parsed types + * (from Iron Router's getQueryWithParsedBooleansFromRoute which converts + * "true"/"false" strings to actual booleans). + * + * @param params - key-value pairs from URL query string + */ +export declare function parseViewSettingsFromUrlParams(params: Record): ViewSettingsFromUrl; +/** + * Serialize a ViewSettingsFromUrl object back to URL query parameter key-value pairs. + * Only includes values that differ from defaults. Useful for future two-way sync. + */ +export declare function serializeViewSettingsToUrlParams(viewSettings: ViewSettingsFromUrl): Record; diff --git a/dist/utils/viewSettingsUrl.js b/dist/utils/viewSettingsUrl.js new file mode 100644 index 00000000..9ed01a39 --- /dev/null +++ b/dist/utils/viewSettingsUrl.js @@ -0,0 +1,147 @@ +import settings from "../settings"; +/** Registry of recognized URL param names, their types, and how they map to ViewSettingsFromUrl keys. */ +const PARAM_PARSERS = { + atomRadiiScale: { key: "atomRadiiScale", type: "number" }, + repetitions: { key: "repetitionsAlongLatticeVectorA", type: "number" }, // handled specially + chemicalConnectivityFactor: { key: "chemicalConnectivityFactor", type: "number" }, + connectivityFactor: { key: "chemicalConnectivityFactor", type: "number" }, // alias + isViewAdjustable: { key: "isViewAdjustable", type: "boolean" }, + orthographicCamera: { key: "orthographicCamera", type: "boolean" }, + bonds: { key: "bonds", type: "boolean" }, + axes: { key: "axes", type: "boolean" }, + autoRotate: { key: "autoRotate", type: "boolean" }, + elementLabels: { key: "elementLabels", type: "boolean" }, + coordinateLabels: { key: "coordinateLabels", type: "boolean" }, + conventionalCell: { key: "conventionalCell", type: "boolean" }, +}; +function parseNumber(value) { + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} +function parseBoolean(value) { + if (value === "true" || value === "1") + return true; + if (value === "false" || value === "0") + return false; + return undefined; +} +/** + * Parse URL query parameters into a ViewSettingsFromUrl object. + * Unknown or invalid parameters are silently ignored. + * + * The `repetitions` param supports two formats: + * - Single number (e.g. `repetitions=2`) → applied to all three axes + * - Comma-separated (e.g. `repetitions=2,3,1`) → A=2, B=3, C=1 + * + * Accepts values as strings (from URLSearchParams) or pre-parsed types + * (from Iron Router's getQueryWithParsedBooleansFromRoute which converts + * "true"/"false" strings to actual booleans). + * + * @param params - key-value pairs from URL query string + */ +export function parseViewSettingsFromUrlParams( +// eslint-disable-next-line @typescript-eslint/no-explicit-any +params) { + const result = {}; + for (const [paramName, rawValue] of Object.entries(params)) { + if (rawValue === undefined || rawValue === "") + continue; + const valueStr = String(rawValue); + // Special handling for `repetitions` (comma-separated or single number) + if (paramName === "repetitions") { + const parts = valueStr.split(",").map((s) => s.trim()); + if (parts.length === 1) { + const n = parseNumber(parts[0]); + if (n !== undefined && n >= 1) { + result.repetitionsAlongLatticeVectorA = n; + result.repetitionsAlongLatticeVectorB = n; + result.repetitionsAlongLatticeVectorC = n; + } + } + else if (parts.length === 3) { + const [a, b, c] = parts.map(parseNumber); + if (a !== undefined && a >= 1) + result.repetitionsAlongLatticeVectorA = a; + if (b !== undefined && b >= 1) + result.repetitionsAlongLatticeVectorB = b; + if (c !== undefined && c >= 1) + result.repetitionsAlongLatticeVectorC = c; + } + continue; + } + const parser = PARAM_PARSERS[paramName]; + if (!parser) + continue; + if (parser.type === "number") { + const n = parseNumber(valueStr); + if (n !== undefined) { + result[parser.key] = n; + } + } + else if (parser.type === "boolean") { + // Accept pre-parsed booleans (from Iron Router) or string values + if (typeof rawValue === "boolean") { + result[parser.key] = rawValue; + } + else { + const b = parseBoolean(valueStr); + if (b !== undefined) { + result[parser.key] = b; + } + } + } + } + return result; +} +/** + * Serialize a ViewSettingsFromUrl object back to URL query parameter key-value pairs. + * Only includes values that differ from defaults. Useful for future two-way sync. + */ +export function serializeViewSettingsToUrlParams(viewSettings) { + const params = {}; + if (viewSettings.atomRadiiScale !== undefined && + viewSettings.atomRadiiScale !== settings.atomRadiiScale) { + params.atomRadiiScale = String(viewSettings.atomRadiiScale); + } + if (viewSettings.chemicalConnectivityFactor !== undefined && + viewSettings.chemicalConnectivityFactor !== settings.chemicalConnectivityFactor) { + params.chemicalConnectivityFactor = String(viewSettings.chemicalConnectivityFactor); + } + // Serialize repetitions as comma-separated if any differ from default + const repA = viewSettings.repetitionsAlongLatticeVectorA; + const repB = viewSettings.repetitionsAlongLatticeVectorB; + const repC = viewSettings.repetitionsAlongLatticeVectorC; + if (repA !== undefined || repB !== undefined || repC !== undefined) { + const a = repA !== null && repA !== void 0 ? repA : settings.repetitions; + const b = repB !== null && repB !== void 0 ? repB : settings.repetitions; + const c = repC !== null && repC !== void 0 ? repC : settings.repetitions; + if (a !== settings.repetitions || b !== settings.repetitions || c !== settings.repetitions) { + if (a === b && b === c) { + params.repetitions = String(a); + } + else { + params.repetitions = `${a},${b},${c}`; + } + } + } + // Boolean toggle settings — only include when true (since defaults are false) + const booleanParams = [ + { key: "orthographicCamera", urlKey: "orthographicCamera" }, + { key: "bonds", urlKey: "bonds" }, + { key: "axes", urlKey: "axes" }, + { key: "autoRotate", urlKey: "autoRotate" }, + { key: "elementLabels", urlKey: "elementLabels" }, + { key: "coordinateLabels", urlKey: "coordinateLabels" }, + { key: "conventionalCell", urlKey: "conventionalCell" }, + ]; + for (const { key, urlKey } of booleanParams) { + if (viewSettings[key] !== undefined) { + params[urlKey] = String(viewSettings[key]); + } + } + if (viewSettings.isViewAdjustable !== undefined && + viewSettings.isViewAdjustable !== settings.isViewAdjustable) { + params.isViewAdjustable = String(viewSettings.isViewAdjustable); + } + return params; +} diff --git a/src/components/ThreeDEditor.jsx b/src/components/ThreeDEditor.jsx index 17153cda..e2219858 100644 --- a/src/components/ThreeDEditor.jsx +++ b/src/components/ThreeDEditor.jsx @@ -53,7 +53,12 @@ export class ThreeDEditor extends React.Component { */ constructor(props) { super(props); - const { boundaryConditions, isConventionalCellShown, material } = this.props; + const { + boundaryConditions, + isConventionalCellShown, + material, + initialViewSettings = {}, + } = this.props; // TODO : overloading a bunch of props and state attributes here.. this.state = { // on/off switch for the component @@ -65,17 +70,32 @@ export class ThreeDEditor extends React.Component { // TODO: remove the need for `viewerTriggerResize` // whether to trigger resize viewerTriggerResize: false, - // Settings of the wave viewer + // Settings of the wave viewer, merged with any initial overrides from URL params viewerSettings: { - isViewAdjustable: settings.isViewAdjustable, - atomRadiiScale: settings.atomRadiiScale, - repetitionsAlongLatticeVectorA: settings.repetitions, - repetitionsAlongLatticeVectorB: settings.repetitions, - repetitionsAlongLatticeVectorC: settings.repetitions, - chemicalConnectivityFactor: settings.chemicalConnectivityFactor, + isViewAdjustable: initialViewSettings.isViewAdjustable ?? settings.isViewAdjustable, + atomRadiiScale: initialViewSettings.atomRadiiScale ?? settings.atomRadiiScale, + repetitionsAlongLatticeVectorA: + initialViewSettings.repetitionsAlongLatticeVectorA ?? settings.repetitions, + repetitionsAlongLatticeVectorB: + initialViewSettings.repetitionsAlongLatticeVectorB ?? settings.repetitions, + repetitionsAlongLatticeVectorC: + initialViewSettings.repetitionsAlongLatticeVectorC ?? settings.repetitions, + chemicalConnectivityFactor: + initialViewSettings.chemicalConnectivityFactor ?? + settings.chemicalConnectivityFactor, + }, + // Toggle settings from URL to apply after Wave instance mounts + _initialToggleSettings: { + orthographicCamera: initialViewSettings.orthographicCamera, + bonds: initialViewSettings.bonds, + axes: initialViewSettings.axes, + autoRotate: initialViewSettings.autoRotate, + elementLabels: initialViewSettings.elementLabels, + coordinateLabels: initialViewSettings.coordinateLabels, }, boundaryConditions, - isConventionalCellShown, + isConventionalCellShown: + initialViewSettings.conventionalCell ?? isConventionalCellShown, // material that is originally passed to the component and can be modified in ThreejsEditorModal component. originalMaterial: material, // material that is passed to WaveComponent to be visualized and may have repetition and radius adjusted. @@ -118,6 +138,36 @@ export class ThreeDEditor extends React.Component { componentDidMount() { this.addHotKeyListener(); window.addEventListener("message", this.handleMessage); + this._applyInitialToggleSettings(); + } + + /** + * Apply toggle-based view settings from URL params after the Wave instance is mounted. + * These settings are imperative (they toggle state on the Wave class instance), + * so they must be applied after componentDidMount when WaveComponent.wave exists. + */ + _applyInitialToggleSettings() { + const { _initialToggleSettings } = this.state; + if (!_initialToggleSettings || !this.WaveComponent?.wave) return; + + if (_initialToggleSettings.orthographicCamera) { + this.handleToggleOrthographicCamera(); + } + if (_initialToggleSettings.bonds) { + this.handleToggleBonds(); + } + if (_initialToggleSettings.axes) { + this.handleToggleAxes(); + } + if (_initialToggleSettings.autoRotate) { + this.handleToggleOrbitControlsAnimation(); + } + if (_initialToggleSettings.elementLabels) { + this.handleToggleElementLabels(); + } + if (_initialToggleSettings.coordinateLabels) { + this.handleToggleCoordinateLabels(); + } } componentWillUnmount() { @@ -799,6 +849,8 @@ ThreeDEditor.propTypes = { boundaryConditions: PropTypes.object, onUpdate: PropTypes.func, isStandalone: PropTypes.bool, + // eslint-disable-next-line react/forbid-prop-types + initialViewSettings: PropTypes.object, }; ThreeDEditor.defaultProps = { @@ -807,4 +859,5 @@ ThreeDEditor.defaultProps = { onUpdate: undefined, editable: false, isStandalone: false, + initialViewSettings: {}, }; diff --git a/src/exports.js b/src/exports.js index e49bd377..bee35039 100644 --- a/src/exports.js +++ b/src/exports.js @@ -1,2 +1,6 @@ export { ThreeDEditor } from "./components/ThreeDEditor"; export { ThreejsEditorModal } from "./components/ThreejsEditorModal"; +export { + parseViewSettingsFromUrlParams, + serializeViewSettingsToUrlParams, +} from "./utils/viewSettingsUrl"; diff --git a/src/utils/viewSettingsUrl.ts b/src/utils/viewSettingsUrl.ts new file mode 100644 index 00000000..b54ce0ba --- /dev/null +++ b/src/utils/viewSettingsUrl.ts @@ -0,0 +1,188 @@ +import settings from "../settings"; + +/** + * View settings that can be passed via URL query parameters. + * Includes both numeric "viewerSettings" values and boolean toggle settings. + */ +export interface ViewSettingsFromUrl { + // Numeric settings (merged into viewerSettings state) + atomRadiiScale?: number; + repetitionsAlongLatticeVectorA?: number; + repetitionsAlongLatticeVectorB?: number; + repetitionsAlongLatticeVectorC?: number; + chemicalConnectivityFactor?: number; + isViewAdjustable?: boolean; + + // Toggle settings (applied imperatively after Wave mount) + orthographicCamera?: boolean; + bonds?: boolean; + axes?: boolean; + autoRotate?: boolean; + elementLabels?: boolean; + coordinateLabels?: boolean; + conventionalCell?: boolean; +} + +/** Registry of recognized URL param names, their types, and how they map to ViewSettingsFromUrl keys. */ +const PARAM_PARSERS: Record< + string, + { key: keyof ViewSettingsFromUrl; type: "number" | "boolean" } +> = { + atomRadiiScale: { key: "atomRadiiScale", type: "number" }, + repetitions: { key: "repetitionsAlongLatticeVectorA", type: "number" }, // handled specially + chemicalConnectivityFactor: { key: "chemicalConnectivityFactor", type: "number" }, + connectivityFactor: { key: "chemicalConnectivityFactor", type: "number" }, // alias + isViewAdjustable: { key: "isViewAdjustable", type: "boolean" }, + orthographicCamera: { key: "orthographicCamera", type: "boolean" }, + bonds: { key: "bonds", type: "boolean" }, + axes: { key: "axes", type: "boolean" }, + autoRotate: { key: "autoRotate", type: "boolean" }, + elementLabels: { key: "elementLabels", type: "boolean" }, + coordinateLabels: { key: "coordinateLabels", type: "boolean" }, + conventionalCell: { key: "conventionalCell", type: "boolean" }, +}; + +function parseNumber(value: string): number | undefined { + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +function parseBoolean(value: string): boolean | undefined { + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + return undefined; +} + +/** + * Parse URL query parameters into a ViewSettingsFromUrl object. + * Unknown or invalid parameters are silently ignored. + * + * The `repetitions` param supports two formats: + * - Single number (e.g. `repetitions=2`) → applied to all three axes + * - Comma-separated (e.g. `repetitions=2,3,1`) → A=2, B=3, C=1 + * + * Accepts values as strings (from URLSearchParams) or pre-parsed types + * (from Iron Router's getQueryWithParsedBooleansFromRoute which converts + * "true"/"false" strings to actual booleans). + * + * @param params - key-value pairs from URL query string + */ +export function parseViewSettingsFromUrlParams( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: Record, +): ViewSettingsFromUrl { + const result: ViewSettingsFromUrl = {}; + + for (const [paramName, rawValue] of Object.entries(params)) { + if (rawValue === undefined || rawValue === "") continue; + + const valueStr = String(rawValue); + + // Special handling for `repetitions` (comma-separated or single number) + if (paramName === "repetitions") { + const parts = valueStr.split(",").map((s) => s.trim()); + if (parts.length === 1) { + const n = parseNumber(parts[0]); + if (n !== undefined && n >= 1) { + result.repetitionsAlongLatticeVectorA = n; + result.repetitionsAlongLatticeVectorB = n; + result.repetitionsAlongLatticeVectorC = n; + } + } else if (parts.length === 3) { + const [a, b, c] = parts.map(parseNumber); + if (a !== undefined && a >= 1) result.repetitionsAlongLatticeVectorA = a; + if (b !== undefined && b >= 1) result.repetitionsAlongLatticeVectorB = b; + if (c !== undefined && c >= 1) result.repetitionsAlongLatticeVectorC = c; + } + continue; + } + + const parser = PARAM_PARSERS[paramName]; + if (!parser) continue; + + if (parser.type === "number") { + const n = parseNumber(valueStr); + if (n !== undefined) { + (result as Record)[parser.key] = n; + } + } else if (parser.type === "boolean") { + // Accept pre-parsed booleans (from Iron Router) or string values + if (typeof rawValue === "boolean") { + (result as Record)[parser.key] = rawValue; + } else { + const b = parseBoolean(valueStr); + if (b !== undefined) { + (result as Record)[parser.key] = b; + } + } + } + } + + return result; +} + +/** + * Serialize a ViewSettingsFromUrl object back to URL query parameter key-value pairs. + * Only includes values that differ from defaults. Useful for future two-way sync. + */ +export function serializeViewSettingsToUrlParams( + viewSettings: ViewSettingsFromUrl, +): Record { + const params: Record = {}; + + if ( + viewSettings.atomRadiiScale !== undefined && + viewSettings.atomRadiiScale !== settings.atomRadiiScale + ) { + params.atomRadiiScale = String(viewSettings.atomRadiiScale); + } + + if ( + viewSettings.chemicalConnectivityFactor !== undefined && + viewSettings.chemicalConnectivityFactor !== settings.chemicalConnectivityFactor + ) { + params.chemicalConnectivityFactor = String(viewSettings.chemicalConnectivityFactor); + } + + // Serialize repetitions as comma-separated if any differ from default + const repA = viewSettings.repetitionsAlongLatticeVectorA; + const repB = viewSettings.repetitionsAlongLatticeVectorB; + const repC = viewSettings.repetitionsAlongLatticeVectorC; + if (repA !== undefined || repB !== undefined || repC !== undefined) { + const a = repA ?? settings.repetitions; + const b = repB ?? settings.repetitions; + const c = repC ?? settings.repetitions; + if (a !== settings.repetitions || b !== settings.repetitions || c !== settings.repetitions) { + if (a === b && b === c) { + params.repetitions = String(a); + } else { + params.repetitions = `${a},${b},${c}`; + } + } + } + + // Boolean toggle settings — only include when true (since defaults are false) + const booleanParams: Array<{ key: keyof ViewSettingsFromUrl; urlKey: string }> = [ + { key: "orthographicCamera", urlKey: "orthographicCamera" }, + { key: "bonds", urlKey: "bonds" }, + { key: "axes", urlKey: "axes" }, + { key: "autoRotate", urlKey: "autoRotate" }, + { key: "elementLabels", urlKey: "elementLabels" }, + { key: "coordinateLabels", urlKey: "coordinateLabels" }, + { key: "conventionalCell", urlKey: "conventionalCell" }, + ]; + for (const { key, urlKey } of booleanParams) { + if (viewSettings[key] !== undefined) { + params[urlKey] = String(viewSettings[key]); + } + } + + if ( + viewSettings.isViewAdjustable !== undefined && + viewSettings.isViewAdjustable !== settings.isViewAdjustable + ) { + params.isViewAdjustable = String(viewSettings.isViewAdjustable); + } + + return params; +} From 93bea08847f3a8384b112bb9b3624f1dbeb86bf1 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 23 Jun 2026 13:22:09 -0700 Subject: [PATCH 2/3] feat: test view settings (antigravity) --- tests/__tests__/utils/viewSettingsUrl.test.js | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/__tests__/utils/viewSettingsUrl.test.js diff --git a/tests/__tests__/utils/viewSettingsUrl.test.js b/tests/__tests__/utils/viewSettingsUrl.test.js new file mode 100644 index 00000000..602636dd --- /dev/null +++ b/tests/__tests__/utils/viewSettingsUrl.test.js @@ -0,0 +1,203 @@ +import expect from "expect"; + +import { + parseViewSettingsFromUrlParams, + serializeViewSettingsToUrlParams, +} from "../../../src/utils/viewSettingsUrl"; + +describe("parseViewSettingsFromUrlParams", () => { + it("returns empty object for empty params", () => { + expect(parseViewSettingsFromUrlParams({})).toEqual({}); + }); + + it("ignores unknown params", () => { + expect(parseViewSettingsFromUrlParams({ foo: "bar", xyz: "123" })).toEqual({}); + }); + + it("parses atomRadiiScale as a number", () => { + const result = parseViewSettingsFromUrlParams({ atomRadiiScale: "0.5" }); + expect(result.atomRadiiScale).toBe(0.5); + }); + + it("ignores invalid number values", () => { + const result = parseViewSettingsFromUrlParams({ atomRadiiScale: "abc" }); + expect(result.atomRadiiScale).toBeUndefined(); + }); + + it("parses chemicalConnectivityFactor", () => { + const result = parseViewSettingsFromUrlParams({ chemicalConnectivityFactor: "1.2" }); + expect(result.chemicalConnectivityFactor).toBe(1.2); + }); + + it("parses connectivityFactor as alias for chemicalConnectivityFactor", () => { + const result = parseViewSettingsFromUrlParams({ connectivityFactor: "0.8" }); + expect(result.chemicalConnectivityFactor).toBe(0.8); + }); + + describe("repetitions", () => { + it("parses single number for all axes", () => { + const result = parseViewSettingsFromUrlParams({ repetitions: "3" }); + expect(result.repetitionsAlongLatticeVectorA).toBe(3); + expect(result.repetitionsAlongLatticeVectorB).toBe(3); + expect(result.repetitionsAlongLatticeVectorC).toBe(3); + }); + + it("parses comma-separated values for individual axes", () => { + const result = parseViewSettingsFromUrlParams({ repetitions: "2,3,1" }); + expect(result.repetitionsAlongLatticeVectorA).toBe(2); + expect(result.repetitionsAlongLatticeVectorB).toBe(3); + expect(result.repetitionsAlongLatticeVectorC).toBe(1); + }); + + it("ignores repetitions less than 1", () => { + const result = parseViewSettingsFromUrlParams({ repetitions: "0" }); + expect(result.repetitionsAlongLatticeVectorA).toBeUndefined(); + }); + + it("handles spaces in comma-separated values", () => { + const result = parseViewSettingsFromUrlParams({ repetitions: "2, 3, 4" }); + expect(result.repetitionsAlongLatticeVectorA).toBe(2); + expect(result.repetitionsAlongLatticeVectorB).toBe(3); + expect(result.repetitionsAlongLatticeVectorC).toBe(4); + }); + + it("ignores two-value repetitions (neither 1 nor 3 values)", () => { + const result = parseViewSettingsFromUrlParams({ repetitions: "2,3" }); + expect(result.repetitionsAlongLatticeVectorA).toBeUndefined(); + }); + }); + + describe("boolean params", () => { + it("parses string 'true' as true", () => { + const result = parseViewSettingsFromUrlParams({ bonds: "true" }); + expect(result.bonds).toBe(true); + }); + + it("parses string 'false' as false", () => { + const result = parseViewSettingsFromUrlParams({ bonds: "false" }); + expect(result.bonds).toBe(false); + }); + + it("parses '1' as true", () => { + const result = parseViewSettingsFromUrlParams({ bonds: "1" }); + expect(result.bonds).toBe(true); + }); + + it("parses '0' as false", () => { + const result = parseViewSettingsFromUrlParams({ bonds: "0" }); + expect(result.bonds).toBe(false); + }); + + it("accepts pre-parsed boolean values (from Iron Router)", () => { + const result = parseViewSettingsFromUrlParams({ bonds: true }); + expect(result.bonds).toBe(true); + }); + + it("ignores invalid boolean values", () => { + const result = parseViewSettingsFromUrlParams({ bonds: "maybe" }); + expect(result.bonds).toBeUndefined(); + }); + + it("parses all boolean settings", () => { + const result = parseViewSettingsFromUrlParams({ + orthographicCamera: "true", + bonds: "true", + axes: "true", + autoRotate: "true", + elementLabels: "true", + coordinateLabels: "true", + conventionalCell: "true", + isViewAdjustable: "false", + }); + expect(result.orthographicCamera).toBe(true); + expect(result.bonds).toBe(true); + expect(result.axes).toBe(true); + expect(result.autoRotate).toBe(true); + expect(result.elementLabels).toBe(true); + expect(result.coordinateLabels).toBe(true); + expect(result.conventionalCell).toBe(true); + expect(result.isViewAdjustable).toBe(false); + }); + }); + + it("parses a realistic URL with mixed params", () => { + const result = parseViewSettingsFromUrlParams({ + atomRadiiScale: "0.5", + bonds: "true", + repetitions: "2,2,2", + orthographicCamera: "true", + }); + expect(result).toEqual({ + atomRadiiScale: 0.5, + bonds: true, + repetitionsAlongLatticeVectorA: 2, + repetitionsAlongLatticeVectorB: 2, + repetitionsAlongLatticeVectorC: 2, + orthographicCamera: true, + }); + }); + + it("ignores empty string values", () => { + const result = parseViewSettingsFromUrlParams({ atomRadiiScale: "", bonds: "" }); + expect(result).toEqual({}); + }); +}); + +describe("serializeViewSettingsToUrlParams", () => { + it("returns empty object for default settings", () => { + expect(serializeViewSettingsToUrlParams({})).toEqual({}); + }); + + it("serializes non-default atomRadiiScale", () => { + const params = serializeViewSettingsToUrlParams({ atomRadiiScale: 0.5 }); + expect(params.atomRadiiScale).toBe("0.5"); + }); + + it("does not serialize default atomRadiiScale (0.2)", () => { + const params = serializeViewSettingsToUrlParams({ atomRadiiScale: 0.2 }); + expect(params.atomRadiiScale).toBeUndefined(); + }); + + it("serializes uniform repetitions as single number", () => { + const params = serializeViewSettingsToUrlParams({ + repetitionsAlongLatticeVectorA: 3, + repetitionsAlongLatticeVectorB: 3, + repetitionsAlongLatticeVectorC: 3, + }); + expect(params.repetitions).toBe("3"); + }); + + it("serializes non-uniform repetitions as comma-separated", () => { + const params = serializeViewSettingsToUrlParams({ + repetitionsAlongLatticeVectorA: 2, + repetitionsAlongLatticeVectorB: 3, + repetitionsAlongLatticeVectorC: 1, + }); + expect(params.repetitions).toBe("2,3,1"); + }); + + it("serializes boolean true values", () => { + const params = serializeViewSettingsToUrlParams({ bonds: true, axes: true }); + expect(params.bonds).toBe("true"); + expect(params.axes).toBe("true"); + }); + + it("serializes boolean false values", () => { + const params = serializeViewSettingsToUrlParams({ bonds: false }); + expect(params.bonds).toBe("false"); + }); + + it("round-trips: parse(serialize(settings)) === settings", () => { + const original = { + atomRadiiScale: 0.5, + bonds: true, + orthographicCamera: true, + repetitionsAlongLatticeVectorA: 2, + repetitionsAlongLatticeVectorB: 3, + repetitionsAlongLatticeVectorC: 1, + }; + const serialized = serializeViewSettingsToUrlParams(original); + const parsed = parseViewSettingsFromUrlParams(serialized); + expect(parsed).toEqual(original); + }); +}); From c50cb5094021c3260a80f2c88e2fc890ada82412 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 23 Jun 2026 13:49:50 -0700 Subject: [PATCH 3/3] update: pass view settigns to standalone --- dist/index.d.ts | 2 +- dist/index.js | 8 ++++++-- src/index.jsx | 21 +++++++++++++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/dist/index.d.ts b/dist/index.d.ts index 5c85822a..531a4399 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1 +1 @@ -export function renderThreeDEditor(materialConfig: any, newDomElement: any): void; +export function renderThreeDEditor(materialConfig: any, newDomElement: any, options?: {}): void; diff --git a/dist/index.js b/dist/index.js index 1f7ee52a..4d664d2e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5,16 +5,20 @@ import { Made } from "@mat3ra/made"; import React from "react"; import ReactDOM from "react-dom"; import { ThreeDEditor } from "./components/ThreeDEditor"; +import { parseViewSettingsFromUrlParams } from "./utils/viewSettingsUrl"; // eslint-disable-next-line react/no-render-return-value -const renderThreeDEditor = (materialConfig, newDomElement) => { +const renderThreeDEditor = (materialConfig, newDomElement, options = {}) => { const config = materialConfig || Made.defaultMaterialConfig; const domElement = newDomElement || document.getElementById("root"); if (!domElement) { console.warn("No root element found for rendering the 3D editor"); return; } + // Read view settings from URL query params unless explicitly provided + const initialViewSettings = options.initialViewSettings || + parseViewSettingsFromUrlParams(Object.fromEntries(new URLSearchParams(window.location.search))); const currentMaterial = new Made.Material(config); - ReactDOM.render(_jsx(ThreeDEditor, { editable: true, isStandalone: true, material: currentMaterial }), domElement); + ReactDOM.render(_jsx(ThreeDEditor, { editable: true, isStandalone: true, material: currentMaterial, initialViewSettings: initialViewSettings }), domElement); }; window.renderThreeDEditor = renderThreeDEditor; export { renderThreeDEditor }; diff --git a/src/index.jsx b/src/index.jsx index 7db5e8bd..889dd42d 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -6,8 +6,10 @@ import React from "react"; import ReactDOM from "react-dom"; import { ThreeDEditor } from "./components/ThreeDEditor"; +import { parseViewSettingsFromUrlParams } from "./utils/viewSettingsUrl"; + // eslint-disable-next-line react/no-render-return-value -const renderThreeDEditor = (materialConfig, newDomElement) => { +const renderThreeDEditor = (materialConfig, newDomElement, options = {}) => { const config = materialConfig || Made.defaultMaterialConfig; const domElement = newDomElement || document.getElementById("root"); if (!domElement) { @@ -15,8 +17,23 @@ const renderThreeDEditor = (materialConfig, newDomElement) => { return; } + // Read view settings from URL query params unless explicitly provided + const initialViewSettings = + options.initialViewSettings || + parseViewSettingsFromUrlParams( + Object.fromEntries(new URLSearchParams(window.location.search)), + ); + const currentMaterial = new Made.Material(config); - ReactDOM.render(, domElement); + ReactDOM.render( + , + domElement, + ); }; window.renderThreeDEditor = renderThreeDEditor;