@john-guerra/fisheye-nav 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Stage 1: choose the SLOTS — the contiguous partition of the leaf axis that
3
+ * the positioner lays out. A slot is one of:
4
+ *
5
+ * leaf one real leaf
6
+ * collapsed one closed subtree, standing in for all its leaves ("2019 · 1,204")
7
+ * elision a RUN of adjacent closed siblings, merged ("4 more groups")
8
+ *
9
+ * Two selectors, same output shape, so the positioner and renderers never learn
10
+ * which one ran:
11
+ *
12
+ * selectAll every leaf is its own slot. Honest, proportional, no elision.
13
+ * selectDOI Furnas degree-of-interest: score the leaves, keep the best ones
14
+ * that fit the row budget, close everything else.
15
+ *
16
+ * Why score LEAVES and not internal nodes: a selector that can only choose which
17
+ * subtrees to *open* cannot be bounded. To show the focused leaf you must open
18
+ * its whole ancestor chain, and each open node drags in its siblings — on a
19
+ * 10-year library that is already 48 rows before you have chosen anything. So
20
+ * the budget has to bite at the leaf level, and structure falls out of it:
21
+ * a node is open iff it still holds a kept leaf.
22
+ */
23
+
24
+ import { leavesOf } from "../hierarchy.js";
25
+
26
+ /** @typedef {{node: any|null, parent?: any, nodes?: any[], leafStart: number,
27
+ * leafEnd: number, count?: number}} Slot */
28
+
29
+ /** One slot per leaf. Carries `count` like every other slot — `sizeBy: "count"`
30
+ * weights the column by slot mass, and a slot without it silently weighs zero. */
31
+ export function selectAll(root) {
32
+ return leavesOf(root).map((node) => ({
33
+ node,
34
+ leafStart: node.leafStart,
35
+ leafEnd: node.leafEnd,
36
+ count: node.count,
37
+ }));
38
+ }
39
+
40
+ /** Distance from a node's leaf interval to the focused leaf; 0 if it contains it. */
41
+ export function leafDistance(node, focusLeaf) {
42
+ if (focusLeaf >= node.leafStart && focusLeaf < node.leafEnd) return 0;
43
+ return focusLeaf < node.leafStart
44
+ ? node.leafStart - focusLeaf
45
+ : focusLeaf - (node.leafEnd - 1);
46
+ }
47
+
48
+ /**
49
+ * Hops from `a` up to the lowest common ancestor and back down to `b`. This is
50
+ * Furnas's tree distance, and it is the thing a purely positional distance
51
+ * cannot see: the day before the focused day might be three rows up but a whole
52
+ * YEAR away in the tree, while the focused day's own siblings are structurally
53
+ * adjacent no matter where they land on the axis.
54
+ */
55
+ export function treeDistance(a, b) {
56
+ let x = a;
57
+ let y = b;
58
+ let d = 0;
59
+ while (x.depth > y.depth) {
60
+ x = x.parent;
61
+ d++;
62
+ }
63
+ while (y.depth > x.depth) {
64
+ y = y.parent;
65
+ d++;
66
+ }
67
+ while (x !== y && x.parent && y.parent) {
68
+ x = x.parent;
69
+ y = y.parent;
70
+ d += 2;
71
+ }
72
+ return d;
73
+ }
74
+
75
+ /**
76
+ * Furnas's degree of interest: how badly does the user want to see this leaf,
77
+ * given where they are looking?
78
+ *
79
+ * DOI(leaf) = importance(leaf) - distance(leaf, focus) + mass(leaf)
80
+ *
81
+ * Leaves are ranked by this and the best ones that fit the budget keep a full
82
+ * row; the rest close into their ancestors. This function alone decides what a
83
+ * DOI tree FEELS like, so each term is deliberate:
84
+ *
85
+ * - **importance** is depth measured RELATIVE to the focus, not absolutely. The
86
+ * textbook `-depth` says a deep leaf matters less — true for the *root*'s
87
+ * importance, false for leaves. In a ragged tree a depth-5 leaf isn't less
88
+ * interesting than a depth-2 one, it's just more nested; what actually reads
89
+ * badly is jumping between levels, so we penalise the level CHANGE.
90
+ *
91
+ * - **distance** blends the two notions of "far", because each alone fails:
92
+ * the leaf axis alone gives a clean readable window but is blind to
93
+ * structure; the tree alone keeps the focus's siblings but scatters the
94
+ * survivors across the column. `treeWeight` slides between them.
95
+ * Axis distance goes through `log1p` so the falloff is smooth — a linear
96
+ * penalty makes rank flip sharply as the focus moves, which is exactly what
97
+ * makes rows pop in and out while you drag the cursor.
98
+ *
99
+ * - **mass** is a weak tiebreaker, never a driver. Let it dominate and the lens
100
+ * stops being about where you are and becomes a popularity chart.
101
+ *
102
+ * @param {any} leaf hierarchy node (.depth, .count, .leafStart, .leafEnd, .parent)
103
+ * @param {{leaf: number, node: any}} focus focused leaf index and its node
104
+ * @param {{apiWeight, distanceWeight, countWeight, treeWeight}} opts
105
+ * @returns {number} higher = more interesting = more likely to keep its own row
106
+ */
107
+ export function doiScore(leaf, focus, opts) {
108
+ const importance = -Math.abs(leaf.depth - focus.node.depth) * opts.apiWeight;
109
+
110
+ const axis = Math.log1p(leafDistance(leaf, focus.leaf));
111
+ const tree = treeDistance(leaf, focus.node);
112
+ const t = opts.treeWeight;
113
+ const distance = ((1 - t) * axis + t * tree) * opts.distanceWeight;
114
+
115
+ const mass = Math.log1p(leaf.count) * opts.countWeight;
116
+
117
+ return importance - distance + mass;
118
+ }
119
+
120
+ /**
121
+ * Keep the highest-DOI leaves that fit the row budget; close the rest.
122
+ *
123
+ * Guarantees the property tests lean on:
124
+ * - slots partition [0, nLeaves) contiguously, so mass is always conserved;
125
+ * - the focused leaf ALWAYS keeps its own slot — you can never lose the row you
126
+ * are pointing at, whatever the budget;
127
+ * - slot count <= budget (that is what the K search below is for).
128
+ *
129
+ * @param {any} root
130
+ * @param {{budget: number, focusLeaf: number} & Record<string, number>} opts
131
+ * @returns {Slot[]}
132
+ */
133
+ export function selectDOI(root, opts) {
134
+ const leaves = leavesOf(root);
135
+ const n = leaves.length;
136
+ if (!n) return [];
137
+
138
+ const budget = Math.max(1, Math.floor(opts.budget ?? n));
139
+ if (n <= budget) return selectAll(root);
140
+
141
+ const focusLeaf = Math.max(0, Math.min(opts.focusLeaf ?? 0, n - 1));
142
+ const focus = { leaf: focusLeaf, node: leaves[focusLeaf] };
143
+
144
+ // Rank once. Header cells are excluded: they are force-kept whenever their
145
+ // node is open, so ranking them would just burn budget entries on rows we were
146
+ // going to draw anyway. Ties break toward the focus, then by leaf order, so the
147
+ // result is deterministic (a property test asserts it).
148
+ const ranked = leaves
149
+ .map((leaf, i) => ({ leaf, i, score: doiScore(leaf, focus, opts) }))
150
+ .filter(({ leaf }) => !leaf.data?.header)
151
+ .sort(
152
+ (a, b) =>
153
+ b.score - a.score ||
154
+ Math.abs(a.i - focusLeaf) - Math.abs(b.i - focusLeaf) ||
155
+ a.i - b.i,
156
+ );
157
+
158
+ // Closing leaves ADDS rows back (a closed subtree is still one row), so K is
159
+ // not simply the budget. Walk K down until the realized slot count fits. It
160
+ // converges fast — closed rows only ever shrink as K shrinks.
161
+ for (let K = Math.min(budget, n); K >= 1; K--) {
162
+ const keep = new Set(ranked.slice(0, K).map((r) => r.leaf));
163
+ keep.add(focus.node);
164
+ const slots = buildSlots(root, keep);
165
+ if (slots.length <= budget) return slots;
166
+ }
167
+ return buildSlots(root, new Set([focus.node]));
168
+ }
169
+
170
+ /**
171
+ * Turn a set of kept leaves into the slot partition.
172
+ *
173
+ * A node is OPEN iff it still contains a kept leaf. Under an open node, each
174
+ * child is either open (recurse) or closed (one slot). Adjacent closed children
175
+ * merge into a single elision slot — without that merge, a wide node whose
176
+ * children are all closed would spend one row per child and blow the budget it
177
+ * was supposed to be saving.
178
+ */
179
+ function buildSlots(root, keep) {
180
+ const holdsKept = new Map();
181
+ root.eachAfter((n) => {
182
+ if (!n.children?.length) return holdsKept.set(n, keep.has(n));
183
+ holdsKept.set(
184
+ n,
185
+ n.children.some((c) => holdsKept.get(c)),
186
+ );
187
+ });
188
+
189
+ const slots = [];
190
+ let run = null; // the closed run currently being accumulated
191
+
192
+ const flush = () => {
193
+ if (!run) return;
194
+ slots.push(
195
+ run.nodes.length === 1
196
+ ? {
197
+ node: run.nodes[0],
198
+ leafStart: run.leafStart,
199
+ leafEnd: run.leafEnd,
200
+ count: run.count,
201
+ }
202
+ : {
203
+ node: null,
204
+ parent: run.parent,
205
+ nodes: run.nodes,
206
+ leafStart: run.leafStart,
207
+ leafEnd: run.leafEnd,
208
+ count: run.count,
209
+ },
210
+ );
211
+ run = null;
212
+ };
213
+
214
+ (function descend(node) {
215
+ for (const child of node.children ?? []) {
216
+ // A header always survives while its parent is open — a node that shows
217
+ // its children but not its own label is worse than useless.
218
+ const isHeader = child.data?.header;
219
+ const open = holdsKept.get(child) && child.children?.length;
220
+ const kept =
221
+ isHeader || (holdsKept.get(child) && !child.children?.length);
222
+
223
+ if (open || kept) {
224
+ flush(); // a visible child breaks the closed run
225
+ if (open) descend(child);
226
+ else
227
+ slots.push({
228
+ node: child,
229
+ leafStart: child.leafStart,
230
+ leafEnd: child.leafEnd,
231
+ count: child.count,
232
+ });
233
+ continue;
234
+ }
235
+
236
+ // Closed: accumulate into the current run of adjacent closed siblings.
237
+ if (run && run.parent === node) {
238
+ run.nodes.push(child);
239
+ run.leafEnd = child.leafEnd;
240
+ run.count += child.count;
241
+ } else {
242
+ flush();
243
+ run = {
244
+ parent: node,
245
+ nodes: [child],
246
+ leafStart: child.leafStart,
247
+ leafEnd: child.leafEnd,
248
+ count: child.count,
249
+ };
250
+ }
251
+ }
252
+ flush(); // a run never spans out of its parent — that would break containment
253
+ })(root);
254
+
255
+ return slots;
256
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * React wrapper. Written with createElement rather than JSX so the package needs
3
+ * no Babel step — the whole point of the vanilla core is that the wrappers stay
4
+ * this thin.
5
+ *
6
+ * <FisheyeNav data={rows} keys={["year","month","day"]}
7
+ * selected={path} onSelect={setPath} />
8
+ */
9
+
10
+ import { createElement, useEffect, useRef } from "react";
11
+ import { fisheyeNav } from "../fisheyeNav.js";
12
+
13
+ export function FisheyeNav({
14
+ data,
15
+ keys,
16
+ root,
17
+ children,
18
+ style = "flat",
19
+ layout = "hybrid",
20
+ selected = null,
21
+ onSelect,
22
+ className,
23
+ ...options
24
+ }) {
25
+ const hostRef = useRef(null);
26
+ const navRef = useRef(null);
27
+ // The handler is read at event time, so a re-render with a new closure does not
28
+ // require tearing the widget down and rebuilding it.
29
+ const onSelectRef = useRef(onSelect);
30
+ onSelectRef.current = onSelect;
31
+
32
+ useEffect(() => {
33
+ const nav = fisheyeNav({
34
+ data,
35
+ keys,
36
+ root,
37
+ children,
38
+ style,
39
+ layout,
40
+ selected,
41
+ ...options,
42
+ });
43
+ nav.addEventListener("input", () => onSelectRef.current?.(nav.value));
44
+ hostRef.current.appendChild(nav);
45
+ navRef.current = nav;
46
+ return () => {
47
+ nav.destroy?.();
48
+ nav.remove();
49
+ navRef.current = null;
50
+ };
51
+ // Mount once; every prop change flows through the update effects below.
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, []);
54
+
55
+ useEffect(() => {
56
+ navRef.current?.update({
57
+ data,
58
+ keys,
59
+ root,
60
+ children,
61
+ style,
62
+ layout,
63
+ ...options,
64
+ });
65
+ });
66
+
67
+ useEffect(() => {
68
+ const nav = navRef.current;
69
+ // `.value =` is the silent setter: it repaints the highlight without firing
70
+ // `input`, so a controlled parent cannot loop.
71
+ if (nav && nav.value !== selected) nav.value = selected;
72
+ }, [selected]);
73
+
74
+ return createElement("div", {
75
+ ref: hostRef,
76
+ className,
77
+ style: { width: "100%", height: "100%" },
78
+ });
79
+ }
80
+
81
+ export default FisheyeNav;
@@ -0,0 +1,282 @@
1
+ /**
2
+ * The settings gear + popover, owned by the widget.
3
+ *
4
+ * Same contract as @john-guerra/d3-zoomable-axis's scent controls, deliberately:
5
+ * controls a ⚙ that live-tunes the lens. On by default; pass false to
6
+ * suppress it and drive the options from your own UI.
7
+ * persistKey a localStorage key, so a user's tuned lens survives a reload.
8
+ * Pass none and listen for the "settings" event to use your own
9
+ * store instead.
10
+ *
11
+ * A consumer should not have to hand-roll this — AutoGallery did, and every
12
+ * other consumer would have too.
13
+ */
14
+
15
+ import { LAYOUTS } from "../layout/index.js";
16
+ import { SIZE_BY } from "../layout/position.js";
17
+ import { BAR_SCALES } from "../layout/scales.js";
18
+ import { STYLES } from "./index.js";
19
+
20
+ /** What the panel tunes AND persists. Never data, accessors, or sizes. */
21
+ export const PERSIST_KEYS = [
22
+ "style",
23
+ "layout",
24
+ "sizeBy",
25
+ "distortion",
26
+ "minRowPx",
27
+ "barScale",
28
+ "apiWeight",
29
+ "distanceWeight",
30
+ "treeWeight",
31
+ "countWeight",
32
+ ];
33
+
34
+ export function readStore(key) {
35
+ if (!key || typeof localStorage === "undefined") return null;
36
+ try {
37
+ const s = JSON.parse(localStorage.getItem(key) ?? "null");
38
+ return s && typeof s === "object" ? s : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export function writeStore(key, obj) {
45
+ if (!key || typeof localStorage === "undefined") return;
46
+ try {
47
+ const picked = {};
48
+ for (const k of PERSIST_KEYS) if (k in obj) picked[k] = obj[k];
49
+ localStorage.setItem(key, JSON.stringify(picked));
50
+ } catch {
51
+ /* quota / disabled storage — persistence is best-effort */
52
+ }
53
+ }
54
+
55
+ /** Keep only known keys with sane values: a stale or hand-edited store must not
56
+ * be able to wedge the widget into an unrenderable state. */
57
+ export function sanitize(raw, defaults) {
58
+ const out = {};
59
+ if (!raw) return out;
60
+ const num = (v, lo, hi) => {
61
+ const n = Number(v);
62
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : undefined;
63
+ };
64
+ const oneOf = (v, list) => (list.includes(v) ? v : undefined);
65
+ const put = (k, v) => {
66
+ if (v !== undefined) out[k] = v;
67
+ };
68
+
69
+ put("style", oneOf(raw.style, STYLES));
70
+ put("layout", oneOf(raw.layout, LAYOUTS));
71
+ put("sizeBy", oneOf(raw.sizeBy, SIZE_BY));
72
+ put("barScale", oneOf(raw.barScale, BAR_SCALES));
73
+ put("distortion", num(raw.distortion, 1, 12));
74
+ put("minRowPx", num(raw.minRowPx, 8, 48));
75
+ put("apiWeight", num(raw.apiWeight, 0, 3));
76
+ put("distanceWeight", num(raw.distanceWeight, 0, 3));
77
+ put("treeWeight", num(raw.treeWeight, 0, 1));
78
+ put("countWeight", num(raw.countWeight, 0, 2));
79
+ return { ...defaults, ...out };
80
+ }
81
+
82
+ const SELECTS = [
83
+ {
84
+ key: "style",
85
+ label: "View",
86
+ options: [
87
+ ["flat", "List (indented)"],
88
+ ["icicle", "Icicle (columns)"],
89
+ ],
90
+ },
91
+ {
92
+ key: "layout",
93
+ label: "Lens",
94
+ options: [
95
+ ["hybrid", "Hybrid — magnify + fold"],
96
+ ["fisheye", "Fisheye — every group"],
97
+ ["doi", "Interest — full rows"],
98
+ ["uniform", "Uniform — no lens"],
99
+ ],
100
+ },
101
+ {
102
+ key: "sizeBy",
103
+ label: "Band size",
104
+ options: [
105
+ ["slots", "Interest (equal)"],
106
+ ["count", "Photos (mass)"],
107
+ ],
108
+ },
109
+ {
110
+ key: "barScale",
111
+ label: "Bars",
112
+ options: [
113
+ ["log", "Log"],
114
+ ["sqrt", "Square root"],
115
+ ["linear", "Linear"],
116
+ ],
117
+ },
118
+ ];
119
+
120
+ const SLIDERS = [
121
+ { key: "distortion", label: "Distortion", min: 1, max: 12, step: 0.5 },
122
+ { key: "minRowPx", label: "Row size", min: 8, max: 48, step: 1, unit: "px" },
123
+ ];
124
+
125
+ // The DOI weights live behind a disclosure: they are where the lens's FEEL comes
126
+ // from, but four extra sliders up front would bury the two knobs most people
127
+ // actually want.
128
+ const WEIGHTS = [
129
+ { key: "distanceWeight", label: "Focus falloff", min: 0, max: 3, step: 0.1 },
130
+ {
131
+ key: "treeWeight",
132
+ label: "Structure vs. list",
133
+ min: 0,
134
+ max: 1,
135
+ step: 0.05,
136
+ },
137
+ { key: "countWeight", label: "Busy groups", min: 0, max: 2, step: 0.05 },
138
+ { key: "apiWeight", label: "Same-level bias", min: 0, max: 3, step: 0.1 },
139
+ ];
140
+
141
+ /**
142
+ * @param {HTMLElement} host the widget root
143
+ * @param {{get:()=>object, set:(patch:object)=>void, reset:()=>void}} io
144
+ * @returns {{sync:()=>void, destroy:()=>void}}
145
+ */
146
+ export function buildControls(host, io) {
147
+ const doc = host.ownerDocument;
148
+ const el = (tag, cls, props) =>
149
+ Object.assign(doc.createElement(tag), { className: cls, ...props });
150
+
151
+ const gear = el("button", "fn-gear", {
152
+ type: "button",
153
+ innerHTML: "&#9881;",
154
+ title: "Lens settings",
155
+ });
156
+ gear.setAttribute("aria-label", "Lens settings");
157
+ gear.setAttribute("aria-expanded", "false");
158
+
159
+ const panel = el("div", "fn-panel");
160
+ panel.hidden = true;
161
+ panel.setAttribute("role", "group");
162
+ panel.setAttribute("aria-label", "Lens settings");
163
+
164
+ const inputs = new Map();
165
+
166
+ const row = (label, control) => {
167
+ const r = el("label", "fn-row-ctl");
168
+ r.appendChild(el("span", "fn-ctl-label", { textContent: label }));
169
+ r.appendChild(control);
170
+ return r;
171
+ };
172
+
173
+ for (const s of SELECTS) {
174
+ const sel = el("select", "fn-ctl-select");
175
+ for (const [value, text] of s.options) {
176
+ sel.appendChild(el("option", "", { value, textContent: text }));
177
+ }
178
+ sel.addEventListener("change", () => io.set({ [s.key]: sel.value }));
179
+ inputs.set(s.key, sel);
180
+ panel.appendChild(row(s.label, sel));
181
+ }
182
+
183
+ const slider = (s) => {
184
+ const wrap = el("span", "fn-ctl-slider");
185
+ const input = el("input", "", {
186
+ type: "range",
187
+ min: String(s.min),
188
+ max: String(s.max),
189
+ step: String(s.step),
190
+ });
191
+ const out = el("span", "fn-ctl-val");
192
+ input.addEventListener("input", () => {
193
+ out.textContent = input.value + (s.unit ?? "");
194
+ io.set({ [s.key]: Number(input.value) });
195
+ });
196
+ wrap.append(input, out);
197
+ inputs.set(s.key, input);
198
+ return { wrap, input, out, spec: s };
199
+ };
200
+
201
+ const sliders = [];
202
+ for (const s of SLIDERS) {
203
+ const made = slider(s);
204
+ sliders.push(made);
205
+ panel.appendChild(row(s.label, made.wrap));
206
+ }
207
+
208
+ const details = el("details", "fn-ctl-more");
209
+ details.appendChild(el("summary", "", { textContent: "Interest weights" }));
210
+ for (const s of WEIGHTS) {
211
+ const made = slider(s);
212
+ sliders.push(made);
213
+ details.appendChild(row(s.label, made.wrap));
214
+ }
215
+ panel.appendChild(details);
216
+
217
+ const reset = el("button", "fn-ctl-reset", {
218
+ type: "button",
219
+ textContent: "Reset",
220
+ });
221
+ reset.addEventListener("click", () => io.reset());
222
+ panel.appendChild(reset);
223
+
224
+ // `.fn-open` is what actually shows the panel — see style.js. `hidden` is kept
225
+ // in sync for assistive tech, but it cannot be trusted to hide anything on its
226
+ // own once the stylesheet sets `display`.
227
+ const toggle = (open = panel.hidden) => {
228
+ panel.hidden = !open;
229
+ panel.classList.toggle("fn-open", open);
230
+ gear.classList.toggle("on", open);
231
+ gear.setAttribute("aria-expanded", String(open));
232
+ };
233
+ gear.addEventListener("click", () => toggle());
234
+
235
+ // Esc closes; a click outside closes. Both are what a popover is expected to
236
+ // do, and neither is worth making every consumer reimplement.
237
+ const onKey = (e) => {
238
+ if (e.key === "Escape" && !panel.hidden) toggle(false);
239
+ };
240
+ const onDocClick = (e) => {
241
+ if (panel.hidden) return;
242
+ if (!panel.contains(e.target) && e.target !== gear) toggle(false);
243
+ };
244
+ host.addEventListener("keydown", onKey);
245
+ doc.addEventListener("click", onDocClick, true);
246
+
247
+ host.append(gear, panel);
248
+
249
+ const sync = () => {
250
+ const o = io.get();
251
+ for (const [key, input] of inputs) {
252
+ const v = o[key];
253
+ if (input.tagName === "SELECT") input.value = String(v);
254
+ else input.value = String(v);
255
+ }
256
+ for (const s of sliders) {
257
+ s.out.textContent = String(o[s.spec.key]) + (s.spec.unit ?? "");
258
+ }
259
+ // Distortion means nothing without a lens; say so rather than letting the
260
+ // user drag a slider that does nothing.
261
+ const lensed = o.layout === "fisheye" || o.layout === "hybrid";
262
+ const dist = inputs.get("distortion");
263
+ dist.disabled = !lensed;
264
+ dist.closest(".fn-row-ctl").classList.toggle("fn-disabled", !lensed);
265
+ // Likewise the interest weights only matter when DOI is doing the selecting.
266
+ const doi = o.layout === "doi" || o.layout === "hybrid";
267
+ details.classList.toggle("fn-disabled", !doi);
268
+ for (const w of WEIGHTS) inputs.get(w.key).disabled = !doi;
269
+ };
270
+
271
+ sync();
272
+
273
+ return {
274
+ sync,
275
+ destroy() {
276
+ host.removeEventListener("keydown", onKey);
277
+ doc.removeEventListener("click", onDocClick, true);
278
+ gear.remove();
279
+ panel.remove();
280
+ },
281
+ };
282
+ }