Skip to content
Closed
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
7 changes: 1 addition & 6 deletions src/main_python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,7 @@ configState.add("volumeRequests", volumeHandler.requestState);
let sharedState: Trackable | undefined = viewer.state;

if (window.location.hash) {
const hashBinding = viewer.registerDisposer(
new UrlHashBinding(
viewer.state,
viewer.dataSourceProvider.sharedKvStoreContext,
),
);
const hashBinding = viewer.registerDisposer(new UrlHashBinding(viewer));
hashBinding.updateFromUrlHash();
sharedState = undefined;
}
Expand Down
16 changes: 6 additions & 10 deletions src/ui/default_viewer_setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,12 @@ export function setupDefaultViewer(options?: Partial<MinimalViewerOptions>) {
setDefaultInputEventBindings(viewer.inputEventBindings);

const hashBinding = viewer.registerDisposer(
new UrlHashBinding(
viewer.state,
viewer.dataSourceProvider.sharedKvStoreContext,
{
defaultFragment:
typeof NEUROGLANCER_DEFAULT_STATE_FRAGMENT !== "undefined"
? NEUROGLANCER_DEFAULT_STATE_FRAGMENT
: undefined,
},
),
new UrlHashBinding(viewer, {
defaultFragment:
typeof NEUROGLANCER_DEFAULT_STATE_FRAGMENT !== "undefined"
? NEUROGLANCER_DEFAULT_STATE_FRAGMENT
: undefined,
}),
);
viewer.registerDisposer(
hashBinding.parseError.changed.add(() => {
Expand Down
45 changes: 34 additions & 11 deletions src/ui/url_hash_binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,17 @@
* limitations under the License.
*/

import { debounce } from "lodash-es";
import type { SharedKvStoreContext } from "#src/kvstore/frontend.js";
import { StatusMessage } from "#src/status.js";
import { WatchableValue } from "#src/trackable_value.js";
import { dynamicDebounce } from "#src/util/debounce.js";
import { RefCounted } from "#src/util/disposable.js";
import {
bigintToStringJsonReplacer,
urlSafeParse,
verifyObject,
} from "#src/util/json.js";
import type { Trackable } from "#src/util/trackable.js";
import { getCachedJson } from "#src/util/trackable.js";
import type { Viewer } from "#src/viewer.js";

/**
* @file Implements a binding between a Trackable value and the URL hash state.
Expand Down Expand Up @@ -68,23 +67,47 @@ export class UrlHashBinding extends RefCounted {

private defaultFragment: string;

get root() {
return this.viewer.state;
}

get sharedKvStoreContext() {
return this.viewer.dataSourceProvider.sharedKvStoreContext;
}

constructor(
public root: Trackable,
public sharedKvStoreContext: SharedKvStoreContext,
private viewer: Viewer,
options: UrlHashBindingOptions = {},
) {
super();
const { updateDelayMilliseconds = 200, defaultFragment = "{}" } = options;
const { defaultFragment = "{}" } = options;
const { root } = this;
this.registerEventListener(window, "hashchange", () =>
this.updateFromUrlHash(),
);
const throttledSetUrlHash = debounce(
() => this.setUrlHash(),
updateDelayMilliseconds,
{ maxWait: updateDelayMilliseconds * 2 },
const throttledSetUrlHash = this.registerDisposer(
dynamicDebounce(
() => this.setUrlHash(),
viewer.urlHashRateLimit,
(wait) => ({ maxWait: wait * 2 }),
),
);
this.registerDisposer(root.changed.add(throttledSetUrlHash));
this.registerDisposer(() => throttledSetUrlHash.cancel());
// try to update the url before the user might attempt to be copying it
window.addEventListener("blur", () => {
throttledSetUrlHash.flush();
});
// mouseleave works better (occurs earlier) than blur
document.addEventListener("mouseleave", () => {
throttledSetUrlHash.flush();
});
// update url for the select url shortcut (ctrl+l/cmd+l)
// select url triggers the blur event, but for chrome, it occurs too late for the url to be updated
window.addEventListener("keydown", (event) => {
Copy link
Contributor

@seankmartin seankmartin Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might trigger the blur event as well so not sure if both listening for the blur event and listening for the ctrl+l/cmd+l press are needed? I see from your PR message though that this seems to be needed, since the URL doesn't update despite the event triggering. Maybe can add a comment in the code about that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I added more detail there. I think we can probably remove the blur event, I need to test it on more browsers

if (event.key === "l") {
throttledSetUrlHash.flush();
}
});
this.defaultFragment = defaultFragment;
}

Expand Down
1 change: 1 addition & 0 deletions src/ui/viewer_settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export class ViewerSettingsPanel extends SidePanel {
"Concurrent chunk requests",
viewer.chunkQueueManager.capacities.download.itemLimit,
);
addLimitWidget("Url update rate limit (ms)", viewer.urlHashRateLimit);

const addCheckbox = (
label: string,
Expand Down
50 changes: 50 additions & 0 deletions src/util/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { DebouncedFunc, DebounceSettings } from "lodash-es";
import { debounce } from "lodash-es";
import type { WatchableValue } from "#src/trackable_value.js";

export function dynamicDebounce<T extends (...args: any) => any>(
func: T,
wait: WatchableValue<number>,
options?: (wait: number) => DebounceSettings,
) {
let debouncedFunc: DebouncedFunc<T> | undefined = undefined;
const updateDebounce = () => {
debouncedFunc?.flush(); // or cancel
debouncedFunc = debounce(func, wait.value, options?.(wait.value));
};
const unregister = wait.changed.add(updateDebounce);
updateDebounce();
return Object.assign(
(...args: Parameters<T>) => {
return debouncedFunc!(...args);
},
{
cancel: () => {
debouncedFunc?.cancel();
},
dispose: () => {
debouncedFunc?.cancel();
unregister();
},
flush: () => {
debouncedFunc?.flush();
},
},
);
}
3 changes: 3 additions & 0 deletions src/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import { vec3 } from "#src/util/geom.js";
import {
parseFixedLengthArray,
verifyFinitePositiveFloat,
verifyNonnegativeInt,
verifyObject,
verifyOptionalObjectProperty,
verifyString,
Expand Down Expand Up @@ -248,6 +249,7 @@ class TrackableViewerState extends CompoundTrackable {
this.add("projectionScale", viewer.projectionScale);
this.add("projectionDepth", viewer.projectionDepthRange);
this.add("layers", viewer.layerSpecification);
this.add("urlHashRateLimit", viewer.urlHashRateLimit);
this.add("showAxisLines", viewer.showAxisLines);
this.add("wireFrame", viewer.wireFrame);
this.add("enableAdaptiveDownsampling", viewer.enableAdaptiveDownsampling);
Expand Down Expand Up @@ -422,6 +424,7 @@ export class Viewer extends RefCounted implements ViewerState {
selectedLayer = this.registerDisposer(
new SelectedLayerState(this.layerManager.addRef()),
);
urlHashRateLimit = new TrackableValue<number>(200, verifyNonnegativeInt);
showAxisLines = new TrackableBoolean(true, true);
wireFrame = new TrackableBoolean(false, false);
enableAdaptiveDownsampling = new TrackableBoolean(true, true);
Expand Down
Loading