Skip to content

Commit 423eea2

Browse files
committed
feat(url_hash_binding) added setting to rate limit url state updates
1 parent ce31ec9 commit 423eea2

File tree

6 files changed

+86
-27
lines changed

6 files changed

+86
-27
lines changed

src/main_python.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,7 @@ configState.add("volumeRequests", volumeHandler.requestState);
147147
let sharedState: Trackable | undefined = viewer.state;
148148

149149
if (window.location.hash) {
150-
const hashBinding = viewer.registerDisposer(
151-
new UrlHashBinding(
152-
viewer.state,
153-
viewer.dataSourceProvider.sharedKvStoreContext,
154-
),
155-
);
150+
const hashBinding = viewer.registerDisposer(new UrlHashBinding(viewer));
156151
hashBinding.updateFromUrlHash();
157152
sharedState = undefined;
158153
}

src/ui/default_viewer_setup.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,12 @@ export function setupDefaultViewer(options?: Partial<MinimalViewerOptions>) {
3535
setDefaultInputEventBindings(viewer.inputEventBindings);
3636

3737
const hashBinding = viewer.registerDisposer(
38-
new UrlHashBinding(
39-
viewer.state,
40-
viewer.dataSourceProvider.sharedKvStoreContext,
41-
{
42-
defaultFragment:
43-
typeof NEUROGLANCER_DEFAULT_STATE_FRAGMENT !== "undefined"
44-
? NEUROGLANCER_DEFAULT_STATE_FRAGMENT
45-
: undefined,
46-
},
47-
),
38+
new UrlHashBinding(viewer, {
39+
defaultFragment:
40+
typeof NEUROGLANCER_DEFAULT_STATE_FRAGMENT !== "undefined"
41+
? NEUROGLANCER_DEFAULT_STATE_FRAGMENT
42+
: undefined,
43+
}),
4844
);
4945
viewer.registerDisposer(
5046
hashBinding.parseError.changed.add(() => {

src/ui/url_hash_binding.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,17 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { debounce } from "lodash-es";
18-
import type { SharedKvStoreContext } from "#src/kvstore/frontend.js";
1917
import { StatusMessage } from "#src/status.js";
2018
import { WatchableValue } from "#src/trackable_value.js";
19+
import { dynamicDebounce } from "#src/util/debounce.js";
2120
import { RefCounted } from "#src/util/disposable.js";
2221
import {
2322
bigintToStringJsonReplacer,
2423
urlSafeParse,
2524
verifyObject,
2625
} from "#src/util/json.js";
27-
import type { Trackable } from "#src/util/trackable.js";
2826
import { getCachedJson } from "#src/util/trackable.js";
27+
import type { Viewer } from "#src/viewer.js";
2928

3029
/**
3130
* @file Implements a binding between a Trackable value and the URL hash state.
@@ -68,23 +67,38 @@ export class UrlHashBinding extends RefCounted {
6867

6968
private defaultFragment: string;
7069

70+
get root() {
71+
return this.viewer.state;
72+
}
73+
74+
get sharedKvStoreContext() {
75+
return this.viewer.dataSourceProvider.sharedKvStoreContext;
76+
}
77+
7178
constructor(
72-
public root: Trackable,
73-
public sharedKvStoreContext: SharedKvStoreContext,
79+
private viewer: Viewer,
7480
options: UrlHashBindingOptions = {},
7581
) {
7682
super();
77-
const { updateDelayMilliseconds = 200, defaultFragment = "{}" } = options;
83+
const { defaultFragment = "{}" } = options;
84+
const { root } = this;
7885
this.registerEventListener(window, "hashchange", () =>
7986
this.updateFromUrlHash(),
8087
);
81-
const throttledSetUrlHash = debounce(
88+
const throttledSetUrlHash = this.registerDisposer(dynamicDebounce(
8289
() => this.setUrlHash(),
83-
updateDelayMilliseconds,
84-
{ maxWait: updateDelayMilliseconds * 2 },
85-
);
90+
viewer.urlHashRateLimit,
91+
(wait) => ({ maxWait: wait * 2 }),
92+
));
8693
this.registerDisposer(root.changed.add(throttledSetUrlHash));
87-
this.registerDisposer(() => throttledSetUrlHash.cancel());
94+
window.addEventListener("blur", () => {
95+
throttledSetUrlHash.flush();
96+
});
97+
// mouseleave works better than blur
98+
// both events don't seem to work with shortcut to copy url (ctrl+l/cmd+l)
99+
document.addEventListener('mouseleave', () => {
100+
throttledSetUrlHash.flush();
101+
});
88102
this.defaultFragment = defaultFragment;
89103
}
90104

@@ -101,6 +115,7 @@ export class UrlHashBinding extends RefCounted {
101115
);
102116
if (stateString !== this.prevStateString) {
103117
this.prevStateString = stateString;
118+
console.log("we will replace the state", stateString);
104119
if (decodeURIComponent(stateString) === "{}") {
105120
history.replaceState(null, "", "#");
106121
} else {

src/ui/viewer_settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export class ViewerSettingsPanel extends SidePanel {
102102
"Concurrent chunk requests",
103103
viewer.chunkQueueManager.capacities.download.itemLimit,
104104
);
105+
addLimitWidget("Url update rate limit (ms)", viewer.urlHashRateLimit);
105106

106107
const addCheckbox = (
107108
label: string,

src/util/debounce.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google Inc.
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { WatchableValue } from "#src/trackable_value.js";
18+
import { debounce, DebouncedFunc, DebounceSettings } from "lodash-es";
19+
20+
export function dynamicDebounce<T extends (...args: any) => any>(
21+
func: T,
22+
wait: WatchableValue<number>,
23+
options?: (wait: number) => DebounceSettings,
24+
) {
25+
let debouncedFunc: DebouncedFunc<T> | undefined = undefined;
26+
const updateDebounce = () => {
27+
debouncedFunc?.flush(); // or cancel
28+
debouncedFunc = debounce(func, wait.value, options?.(wait.value));
29+
};
30+
const unregister = wait.changed.add(updateDebounce);
31+
updateDebounce();
32+
return Object.assign(
33+
(...args: Parameters<T>) => {
34+
return debouncedFunc!(...args);
35+
},
36+
{
37+
cancel: () => {
38+
debouncedFunc?.cancel();
39+
},
40+
dispose: () => {
41+
debouncedFunc?.cancel();
42+
unregister();
43+
},
44+
flush: () => {
45+
debouncedFunc?.flush();
46+
},
47+
},
48+
);
49+
}

src/viewer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ import { vec3 } from "#src/util/geom.js";
115115
import {
116116
parseFixedLengthArray,
117117
verifyFinitePositiveFloat,
118+
verifyNonnegativeInt,
118119
verifyObject,
119120
verifyOptionalObjectProperty,
120121
verifyString,
@@ -248,6 +249,7 @@ class TrackableViewerState extends CompoundTrackable {
248249
this.add("projectionScale", viewer.projectionScale);
249250
this.add("projectionDepth", viewer.projectionDepthRange);
250251
this.add("layers", viewer.layerSpecification);
252+
this.add("urlHashRateLimit", viewer.urlHashRateLimit);
251253
this.add("showAxisLines", viewer.showAxisLines);
252254
this.add("wireFrame", viewer.wireFrame);
253255
this.add("enableAdaptiveDownsampling", viewer.enableAdaptiveDownsampling);
@@ -422,6 +424,7 @@ export class Viewer extends RefCounted implements ViewerState {
422424
selectedLayer = this.registerDisposer(
423425
new SelectedLayerState(this.layerManager.addRef()),
424426
);
427+
urlHashRateLimit = new TrackableValue<number>(200, verifyNonnegativeInt);
425428
showAxisLines = new TrackableBoolean(true, true);
426429
wireFrame = new TrackableBoolean(false, false);
427430
enableAdaptiveDownsampling = new TrackableBoolean(true, true);

0 commit comments

Comments
 (0)