Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Download static/app.js from Sandflow/sand3d-explorer: direct link, hf CLI and curl.
- Browser
- Download file 20.2 kB
-
https://huggingface.co/spaces/Sandflow/sand3d-explorer/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/Sandflow/sand3d-explorer/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Sandflow/sand3d-explorer/resolve/main/static/app.js
20.2 kB
| // Sand3D Explorer: the gallery, the search and the object page, on the Space's JSON API. | |
| ; | |
| const $ = (s, el = document) => el.querySelector(s); | |
| const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); | |
| const fmt = (n) => Number(n).toLocaleString("en-US").replace(/,/g, " "); | |
| const PAGE = 60; | |
| const S = { | |
| facets: null, labels: {}, | |
| q: "", mode: "meaning", dataset: "", family: "", articulated: false, glb: false, parts: false, | |
| offset: 0, total: 0, loading: false, seq: 0, done: false, | |
| objects: new Map(), homeScroll: 0, | |
| }; | |
| async function api(path) { | |
| const r = await fetch(path, { credentials: "same-origin" }); | |
| if (!r.ok) { const e = new Error(`${r.status}`); e.status = r.status; e.body = await r.json().catch(() => ({})); throw e; } | |
| return r.json(); | |
| } | |
| // ------------------------------------------------------------------ boot | |
| async function waitReady() { | |
| const box = $("#loading"); | |
| for (;;) { | |
| let st; | |
| try { st = await api("/api/status"); } catch { st = { stage: "Connecting" }; } | |
| if (st.ready) { box.hidden = true; return; } | |
| box.hidden = false; | |
| const n = st.loaded ? ` · ${fmt(st.loaded)} objects` : ""; | |
| $("#loading-stage").textContent = st.stage === "failed" ? `Failed: ${st.error}` : `${cap(st.stage)}${n}`; | |
| await new Promise((r) => setTimeout(r, 1200)); | |
| } | |
| } | |
| const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s); | |
| async function boot() { | |
| await waitReady(); | |
| S.facets = await api("/api/facets"); | |
| for (const d of S.facets.datasets) S.labels[d.key] = d.label; | |
| readURL(); | |
| if (!S.facets.semantic) S.mode = "words"; | |
| drawFacets(); | |
| bind(); | |
| route(); | |
| } | |
| function readURL() { | |
| const p = new URLSearchParams(location.search); | |
| S.q = p.get("q") || ""; | |
| S.mode = p.get("by") === "words" ? "words" : "meaning"; | |
| S.dataset = p.get("dataset") || ""; | |
| S.family = p.get("family") || ""; | |
| S.articulated = p.get("articulated") === "1"; | |
| S.glb = p.get("glb") === "1"; | |
| S.parts = p.get("parts") === "1"; | |
| } | |
| function writeURL() { | |
| const p = new URLSearchParams(); | |
| if (S.q) p.set("q", S.q); | |
| if (S.q && S.mode === "words") p.set("by", "words"); | |
| if (S.dataset) p.set("dataset", S.dataset); | |
| if (S.family) p.set("family", S.family); | |
| if (S.articulated) p.set("articulated", "1"); | |
| if (S.glb) p.set("glb", "1"); | |
| if (S.parts) p.set("parts", "1"); | |
| const qs = p.toString(); | |
| history.replaceState(null, "", "/" + (qs ? "?" + qs : "")); | |
| } | |
| // ------------------------------------------------------------------ facets and controls | |
| function drawFacets() { | |
| const F = S.facets; | |
| const art = F.datasets.reduce((a, d) => a + d.articulated, 0); | |
| $("#stats").innerHTML = `${fmt(F.total)} objects · ${fmt(art)} articulated · ${fmt(F.glb)} viewable in 3D` + | |
| (F.parts ? ` · ${fmt(F.parts)} parts on their own` : ""); | |
| const chips = [`<button class="chip" data-ds="" aria-pressed="${!S.dataset}">All <span class="n">${fmt(F.total)}</span></button>`] | |
| .concat(F.datasets.map((d) => `<button class="chip" data-ds="${esc(d.key)}" aria-pressed="${S.dataset === d.key}">${esc(d.label)} <span class="n">${fmt(d.count)}</span></button>`)); | |
| $("#datasets").innerHTML = chips.join(""); | |
| drawFamilies(); | |
| $("#ds-list").innerHTML = F.datasets.map((d) => ` | |
| <div class="ds"><h3>${esc(d.label)} <span>${fmt(d.count)}</span></h3> | |
| <p>${esc(d.blurb)}</p> | |
| <p>${fmt(d.articulated)} articulated · ${fmt(d.families.length)} families · ${fmt(d.glb)} in 3D${d.parts ? ` · ${fmt(d.parts)} parts` : ""}</p> | |
| <a href="https://huggingface.co/datasets/${esc(d.repo)}" target="_blank" rel="noopener">${esc(d.repo)}</a></div>`).join(""); | |
| $("#q").value = S.q; | |
| $("#articulated").checked = S.articulated; | |
| $("#glb").checked = S.glb; | |
| drawParts(); | |
| drawMode(); | |
| } | |
| function drawFamilies() { | |
| const sel = $("#family"); | |
| const d = S.facets.datasets.find((x) => x.key === S.dataset); | |
| sel.disabled = !d; | |
| sel.innerHTML = `<option value="">${d ? "All families" : "Pick a dataset first"}</option>` + | |
| (d ? d.families.map(([raw, label, n]) => `<option value="${esc(raw)}"${raw === S.family ? " selected" : ""}>${esc(label)} (${fmt(n)})</option>`).join("") : ""); | |
| } | |
| function drawParts() { | |
| const d = S.facets.datasets.find((x) => x.key === S.dataset); | |
| const t = $("#parts-toggle"); | |
| t.hidden = !(d && d.parts); | |
| if (t.hidden) S.parts = false; | |
| else $("#parts-label").textContent = `Its parts (${fmt(d.parts)})`; | |
| $("#parts").checked = S.parts; | |
| } | |
| function drawMode() { | |
| for (const b of $("#mode").querySelectorAll("button")) { | |
| b.setAttribute("aria-checked", String(b.dataset.mode === S.mode)); | |
| if (b.dataset.mode === "meaning" && !S.facets.semantic) { b.disabled = true; b.title = "Semantic search is not available"; } | |
| } | |
| } | |
| function bind() { | |
| let t; | |
| $("#q").addEventListener("input", () => { clearTimeout(t); t = setTimeout(() => setQ($("#q").value), 280); }); | |
| $("#search-form").addEventListener("submit", (e) => { e.preventDefault(); clearTimeout(t); setQ($("#q").value, true); }); | |
| $("#mode").addEventListener("click", (e) => { | |
| const b = e.target.closest("button"); if (!b || b.disabled) return; | |
| S.mode = b.dataset.mode; drawMode(); refresh(); | |
| }); | |
| $("#datasets").addEventListener("click", (e) => { | |
| const b = e.target.closest(".chip"); if (!b) return; | |
| S.dataset = b.dataset.ds; S.family = ""; | |
| for (const c of $("#datasets").querySelectorAll(".chip")) c.setAttribute("aria-pressed", String(c === b)); | |
| drawFamilies(); drawParts(); refresh(); | |
| }); | |
| $("#parts").addEventListener("change", (e) => { S.parts = e.target.checked; S.family = ""; drawFamilies(); refresh(); }); | |
| $("#family").addEventListener("change", (e) => { S.family = e.target.value; refresh(); }); | |
| $("#articulated").addEventListener("change", (e) => { S.articulated = e.target.checked; refresh(); }); | |
| $("#glb").addEventListener("change", (e) => { S.glb = e.target.checked; refresh(); }); | |
| document.addEventListener("click", (e) => { | |
| const a = e.target.closest("a[data-nav], a.card"); | |
| if (a && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { e.preventDefault(); go(a.getAttribute("href")); } | |
| const s = e.target.closest("[data-scroll]"); | |
| if (s) { e.preventDefault(); if (location.pathname !== "/") go("/"); setTimeout(() => $("#about-datasets").scrollIntoView(), 50); } | |
| }); | |
| window.addEventListener("popstate", route); | |
| new IntersectionObserver((es) => { if (es.some((x) => x.isIntersecting)) more(); }, { rootMargin: "800px" }).observe($("#more")); | |
| $("#grid").addEventListener("mouseover", peek); | |
| } | |
| function setQ(v, force) { | |
| v = v.trim(); | |
| if (v === S.q && !force) return; | |
| S.q = v; refresh(); | |
| if (v) scrollTo({ top: $("#toolbar").offsetTop - $("#top").offsetHeight }); | |
| } | |
| // ------------------------------------------------------------------ routing | |
| function go(href) { | |
| S.fromHome = location.pathname === "/"; | |
| if (S.fromHome) { S.homeScroll = scrollY; S.homeURL = "/" + location.search; } | |
| history.pushState(null, "", href); | |
| route(); | |
| } | |
| function route() { | |
| const m = location.pathname.match(/^\/o\/(.+)$/); | |
| if (!m) { S.current = null; dropViewer(); } | |
| if (m) { $("#home").hidden = true; $("#object").hidden = false; showObject(decodeURIComponent(m[1])); return; } | |
| $("#object").hidden = true; $("#object").innerHTML = ""; | |
| $("#home").hidden = false; | |
| document.title = "Sand3D Explorer · Sandflow"; | |
| const before = JSON.stringify([S.q, S.mode, S.dataset, S.family, S.articulated, S.glb, S.parts]); | |
| readURL(); if (!S.facets.semantic) S.mode = "words"; | |
| const after = JSON.stringify([S.q, S.mode, S.dataset, S.family, S.articulated, S.glb, S.parts]); | |
| if (!$("#grid").children.length || before !== after) { drawFacets(); refresh(); } | |
| else requestAnimationFrame(() => scrollTo(0, S.homeScroll)); | |
| } | |
| // ------------------------------------------------------------------ gallery | |
| function refresh() { | |
| writeURL(); | |
| S.offset = 0; S.total = 0; S.done = false; S.seq++; | |
| $("#grid").innerHTML = Array.from({ length: 12 }, () => `<div class="skeleton"></div>`).join(""); | |
| $("#count").innerHTML = " "; | |
| more(true); | |
| } | |
| async function more(first) { | |
| if ((S.loading && !first) || S.done) return; | |
| S.loading = true; | |
| const seq = S.seq; | |
| const p = new URLSearchParams({ q: S.q, mode: S.mode, dataset: S.dataset, family: S.family, | |
| articulated: S.articulated, glb: S.glb, parts: S.parts, offset: S.offset, limit: PAGE }); | |
| try { | |
| const r = await api("/api/search?" + p); | |
| if (seq !== S.seq) return; | |
| if (first) $("#grid").innerHTML = ""; | |
| S.total = r.total; S.offset += r.items.length; S.done = S.offset >= r.total; | |
| $("#grid").insertAdjacentHTML("beforeend", r.items.map((c) => card(c)).join("")); | |
| if (!r.total) $("#grid").innerHTML = `<p class="empty"><b>No object matches.</b> Try fewer words, or search by look.</p>`; | |
| const how = "containing"; | |
| $("#count").innerHTML = r.mode === "all" ? `<b>${fmt(r.total)}</b> objects` | |
| : r.mode === "meaning" ? `Closest renders to “${esc(S.q)}”, out of <b>${fmt(r.total)}</b> objects` | |
| : `<b>${fmt(r.total)}</b> objects ${how} “${esc(S.q)}”`; | |
| $("#more").textContent = S.done ? (r.total ? "End of results" : "") : "Loading more…"; | |
| } catch (e) { | |
| if (e.status === 503) { await waitReady(); S.loading = false; return more(first); } | |
| $("#more").textContent = "Could not load: " + e.message; | |
| } finally { | |
| if (seq === S.seq) S.loading = false; | |
| } | |
| } | |
| function card(c, small) { | |
| const tags = [c.parts ? `<span class="tag">${c.parts} part${c.parts > 1 ? "s" : ""}</span>` : `<span class="tag">${c.loc} lines</span>`]; | |
| if (c.joints) tags.push(`<span class="tag">${c.joints} joint${c.joints > 1 ? "s" : ""}</span>`); | |
| if (c.glb) tags.push(`<span class="tag violet">3D</span>`); | |
| return `<a class="card" href="/o/${encodeURIComponent(c.id)}" data-id="${esc(c.id)}"> | |
| <div class="thumb"><img loading="lazy" decoding="async" src="/r/${encodeURIComponent(c.id)}.png" alt="${esc(c.name)}">${small ? "" : `<pre class="code-peek"></pre>`}</div> | |
| <div class="body"><div class="name" title="${esc(c.name)}">${esc(c.name)}</div> | |
| <div class="meta">${esc(S.labels[c.dataset] || c.dataset)}${c.part ? " part" : ""}${c.family ? ` · ${esc(c.family)}` : ""}</div> | |
| ${small ? "" : `<div class="tags">${tags.join("")}</div>`}</div></a>`; | |
| } | |
| let peekTimer; | |
| function peek(e) { | |
| const a = e.target.closest(".card"); if (!a) return; | |
| const pre = $(".code-peek", a); if (!pre || pre.classList.contains("ready")) return; | |
| clearTimeout(peekTimer); | |
| peekTimer = setTimeout(async () => { | |
| const o = await getObject(a.dataset.id).catch(() => null); | |
| if (!o) return; | |
| pre.textContent = o.program.split("\n").filter((l) => l.trim()).slice(0, 22).join("\n"); | |
| pre.classList.add("ready"); | |
| }, 220); | |
| } | |
| async function getObject(id) { | |
| if (!S.objects.has(id)) S.objects.set(id, api("/api/object/" + encodeURIComponent(id))); | |
| try { return await S.objects.get(id); } catch (e) { S.objects.delete(id); throw e; } | |
| } | |
| // ------------------------------------------------------------------ object page | |
| const ICON = { | |
| copy: `<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg>`, | |
| ext: `<svg viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg>`, | |
| back: `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M15 18l-6-6 6-6"/></svg>`, | |
| }; | |
| async function showObject(id) { | |
| S.current = id; | |
| dropViewer(); | |
| const el = $("#object"); | |
| scrollTo(0, 0); | |
| el.innerHTML = `<button class="back" data-back>${ICON.back} Back to the gallery</button><div class="skeleton" style="aspect-ratio:3"></div>`; | |
| bindBack(el); | |
| let o; | |
| try { o = await getObject(id); } | |
| catch (e) { | |
| if (e.status === 503) { await waitReady(); return showObject(id); } | |
| el.innerHTML = `<button class="back" data-back>${ICON.back} Back to the gallery</button><p class="empty"><b>No object ${esc(id)}.</b></p>`; | |
| return bindBack(el); | |
| } | |
| document.title = `${o.name} · Sand3D Explorer`; | |
| const lic = o.license_status && o.license_status !== "ok" ? ` <span class="pending">(source licence ${esc(o.license_status)})</span>` : ""; | |
| const rows = [ | |
| ["Dataset", `<a href="https://huggingface.co/datasets/${esc(o.repo)}" target="_blank" rel="noopener">${esc(o.repo)}</a>`], | |
| ["Source", esc(o.source_label)], | |
| ...(o.assembly ? [["Assembly", `<a href="/o/${encodeURIComponent(o.assembly.id)}" data-nav>${esc(o.assembly.name)}</a>`]] : []), | |
| ["Family", esc(o.family || "Unclassified")], | |
| ["Parts", o.parts ? fmt(o.parts) : "—"], | |
| ["Joints", o.joints ? `${o.joints}, declared in the program` : "None"], | |
| ["Entry", o.entry ? `<span class="mono">${esc(o.entry)}()</span>` : "The program builds the object as it runs"], | |
| ["Split", esc(o.split === "val" ? "validation" : o.split)], | |
| ["Fidelity", o.f_score != null ? `F-score ${Number(o.f_score).toFixed(3)} against the source object` : "—"], | |
| ["Licence", `${esc(o.license)}${o.source_license && o.source_license !== o.license ? `, source under ${esc(o.source_license)}` : ""}${lic}`], | |
| ["Attribution", esc(o.attribution)], | |
| ["Library", `sand3d ${esc(o.sand3d_version)}`], | |
| ["Id", `<span class="mono">${esc(o.id)}</span>`], | |
| ]; | |
| el.innerHTML = ` | |
| <button class="back" data-back>${ICON.back} Back to the gallery</button> | |
| <div class="obj-head"> | |
| <div class="stage" id="stage"> | |
| ${o.glb ? `<div class="stage-tabs"><button data-view="3d" aria-pressed="true">3D</button><button data-view="render" aria-pressed="false">Render</button></div>` : ""} | |
| <div id="stage-body"></div> | |
| </div> | |
| <div class="obj-info"> | |
| <p class="repere">${esc(o.dataset_label)}${o.part ? " · part" : ""}</p> | |
| <h1>${esc(o.name)}</h1> | |
| <p class="sub">${o.loc} lines of Python${o.joints ? ` · ${o.joints} joint${o.joints > 1 ? "s" : ""}` : ""}</p> | |
| <table class="facts">${rows.map(([k, v]) => `<tr><th>${k}</th><td>${v}</td></tr>`).join("")}</table> | |
| <div class="actions"> | |
| <a class="btn" href="${esc(o.row_url)}" target="_blank" rel="noopener">${ICON.ext} Row on the Hub</a> | |
| <a class="btn" href="${esc(o.usda_url)}" target="_blank" rel="noopener">${ICON.ext} USD file</a> | |
| <a class="btn" href="${esc(o.library_url)}" target="_blank" rel="noopener">${ICON.ext} sand3d on GitHub</a> | |
| </div> | |
| </div> | |
| </div> | |
| <section class="program"> | |
| <div class="program-head"><h2>The program</h2><span class="lines">Python · sand3d ${esc(o.sand3d_version)} · ${o.loc} lines</span></div> | |
| <div class="code"><button class="btn copy" id="copy">${ICON.copy} Copy</button><pre><code id="code"></code></pre></div> | |
| </section> | |
| <section class="similar" id="similar" hidden> | |
| <h2>Similar objects</h2><p>The nearest renders, across every dataset.</p> | |
| <div class="grid" id="similar-grid"></div> | |
| </section>`; | |
| bindBack(el); | |
| drawStage(o, o.glb ? "3d" : "render"); | |
| for (const b of el.querySelectorAll(".stage-tabs button")) b.addEventListener("click", () => { | |
| for (const x of el.querySelectorAll(".stage-tabs button")) x.setAttribute("aria-pressed", String(x === b)); | |
| drawStage(o, b.dataset.view); | |
| }); | |
| $("#code").innerHTML = highlight(o.program); | |
| $("#copy").addEventListener("click", () => copy(o.program, $("#copy"))); | |
| if (o.has_similar) { | |
| const r = await api(`/api/similar/${encodeURIComponent(o.id)}?limit=12`).catch(() => ({ items: [] })); | |
| if (S.current === id) { | |
| $("#similar-grid").innerHTML = r.items.map((c) => card(c, true)).join(""); | |
| $("#similar").hidden = !r.items.length; | |
| } | |
| } | |
| } | |
| function bindBack(el) { | |
| const b = $("[data-back]", el); | |
| if (b) b.addEventListener("click", () => { if (S.fromHome) history.back(); else go(S.homeURL || "/"); }); | |
| } | |
| let viewer = null; | |
| function dropViewer() { if (viewer) { viewer.dispose(); viewer = null; } } | |
| function drawStage(o, view) { | |
| const body = $("#stage-body"); | |
| const img = `/r/${encodeURIComponent(o.id)}.png`; | |
| dropViewer(); | |
| if (view === "3d" && o.glb) { | |
| body.innerHTML = `<div class="three" id="three"><img class="still poster" src="${img}" alt="${esc(o.name)}"><div class="three-loading"><div></div></div></div> | |
| <div class="three-tip" id="three-tip" hidden></div> | |
| <div class="three-bar" id="three-bar" hidden> | |
| <div class="seg" id="three-mode" role="radiogroup" aria-label="View"> | |
| <button data-m="parts" aria-checked="true">Parts</button><button data-m="materials" aria-checked="false">Materials</button><button data-m="mesh" aria-checked="false">Mesh</button> | |
| </div> | |
| <label class="explode"><span>Explode</span><input type="range" id="explode" min="0" max="1" step="0.01" value="0"></label> | |
| <span class="three-info" id="three-info"></span> | |
| </div>`; | |
| const host = $("#three"); | |
| import("/static/viewer.js?v=4").then(({ Viewer }) => { | |
| if (S.current !== o.id) return; | |
| viewer = new Viewer(host, { | |
| onReady: ({ parts }) => { | |
| host.classList.add("ready"); | |
| $("#three-bar").hidden = false; | |
| $("#three-info").textContent = `${parts} part${parts > 1 ? "s" : ""}${o.joints ? ` · ${o.joints} joint${o.joints > 1 ? "s" : ""}` : ""} · drag to turn`; | |
| }, | |
| onHover: (h) => { | |
| const tip = $("#three-tip"); if (!tip) return; | |
| if (!h) { tip.hidden = true; return; } | |
| tip.hidden = false; tip.textContent = h.name; | |
| tip.style.transform = `translate(${h.x + 14}px, ${h.y + 12}px)`; | |
| }, | |
| }); | |
| return viewer.load(o.glb_url); | |
| }).catch((e) => { | |
| host.classList.add("failed"); | |
| $("#three-info") && ($("#three-info").textContent = ""); | |
| body.insertAdjacentHTML("beforeend", `<span class="stage-hint">The 3D view could not load (${esc(e.message || e)}): the render is shown instead</span>`); | |
| }); | |
| $("#three-mode").addEventListener("click", (e) => { | |
| const b = e.target.closest("button"); if (!b || !viewer) return; | |
| for (const x of $("#three-mode").querySelectorAll("button")) x.setAttribute("aria-checked", String(x === b)); | |
| viewer.setMode(b.dataset.m); | |
| }); | |
| $("#explode").addEventListener("input", (e) => viewer && viewer.explode(Number(e.target.value))); | |
| } else { | |
| body.innerHTML = `<img class="still" src="${img}" alt="${esc(o.name)}">${o.glb ? "" : `<span class="stage-hint">Render only: no 3D view for this object yet</span>`}`; | |
| } | |
| } | |
| function copy(text, btn) { | |
| const ok = () => { btn.classList.add("done"); btn.innerHTML = `${ICON.copy} Copied`; setTimeout(() => { btn.classList.remove("done"); btn.innerHTML = `${ICON.copy} Copy`; }, 1600); }; | |
| if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(ok); | |
| else { const t = document.createElement("textarea"); t.value = text; document.body.appendChild(t); t.select(); document.execCommand("copy"); t.remove(); ok(); } | |
| } | |
| // Highlighted HTML split into one element per line, spans reopened across line breaks. | |
| function highlight(src) { | |
| let html = esc(src); | |
| if (window.hljs) html = hljs.highlight(src, { language: "python", ignoreIllegals: true }).value; | |
| html = html.replace(/\bs3d\b/g, '<span class="s3d">s3d</span>'); | |
| const out = []; const open = []; let line = ""; | |
| const re = /(<span[^>]*>)|(<\/span>)|(\n)|([^<\n]+|<)/g; let m; | |
| while ((m = re.exec(html))) { | |
| if (m[1]) { open.push(m[1]); line += m[1]; } | |
| else if (m[2]) { open.pop(); line += m[2]; } | |
| else if (m[3]) { out.push(line + "</span>".repeat(open.length)); line = open.join(""); } | |
| else line += m[4]; | |
| } | |
| out.push(line + "</span>".repeat(open.length)); | |
| return out.map((l) => `<span class="ln">${l || " "}</span>`).join(""); | |
| } | |
| // the toolbar sticks right under the header, whatever height the header wraps to | |
| function headHeight() { document.documentElement.style.setProperty("--head-h", $("#top").offsetHeight + "px"); } | |
| window.addEventListener("resize", headHeight); | |
| document.addEventListener("DOMContentLoaded", () => { headHeight(); boot(); }); | |