2043 lines
96 KiB
HTML
2043 lines
96 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="pt-BR">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||
<title>Brasiliana Fotográfica — Mural do Acervo</title>
|
||
<script type="importmap">
|
||
{
|
||
"imports": {
|
||
"three": "https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js"
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body {
|
||
width: 100%; height: 100%; height: 100dvh; overflow: hidden; background: #0b0b0c;
|
||
font-family: Georgia, 'Times New Roman', serif; color: #eee;
|
||
overscroll-behavior: none; /* no rubber-banding the page behind the wall */
|
||
-webkit-tap-highlight-color: transparent;
|
||
-webkit-text-size-adjust: 100%;
|
||
}
|
||
#canvas { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; }
|
||
|
||
#loading {
|
||
position: fixed; inset: 0; background: #0b0b0c;
|
||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||
/* linear, not ease: an eased fade is perceptually over in half its time */
|
||
transition: opacity .9s linear, background-color 1.4s ease;
|
||
}
|
||
#loading.ready { background: rgba(11,11,12,.78); } /* the wall renders live behind the title card */
|
||
/* scoped to the id, not to every <p> under the cover — the title screen's
|
||
own subtitle is a <p> too, and it is not an uppercase tracking-out line */
|
||
#loading-msg { font-size: .8rem; letter-spacing: .15em; text-transform: uppercase; color: #999; }
|
||
|
||
/* ── opening title screen (shown once data is ready, before entering) ────
|
||
It overlays the cover rather than sitting beside the loading line: as a
|
||
flex sibling it still claimed its share of the row while invisible, which
|
||
squeezed "carregando mural…" into a narrow column down one side — the
|
||
first thing anyone saw, and it read as a page with no stylesheet. */
|
||
#title-screen {
|
||
position: absolute; inset: 0;
|
||
display: flex; flex-direction: column; justify-content: center;
|
||
text-align: center;
|
||
padding: 0 24px;
|
||
padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom);
|
||
opacity: 0; filter: blur(0px); transform: scale(1);
|
||
transition: opacity .9s ease;
|
||
}
|
||
#title-screen.show { opacity: 1; }
|
||
/* the words go before the dark does: they blur out and drift open, the way a
|
||
memory clears — and the veil only lifts once they're gone */
|
||
#title-screen.dissolve {
|
||
opacity: 0; filter: blur(8px); transform: scale(1.04);
|
||
transition: opacity .85s linear, filter .85s ease-in, transform 1.1s ease-out;
|
||
}
|
||
#title-screen .eyebrow {
|
||
font-family: system-ui, sans-serif; font-size: .68rem; letter-spacing: .28em;
|
||
text-transform: uppercase; color: #d8b04a; margin-bottom: 22px;
|
||
}
|
||
#title-screen h1 {
|
||
font-family: Georgia, 'Times New Roman', serif; font-weight: 400;
|
||
font-size: clamp(2rem, 6vw, 3.6rem); letter-spacing: .01em; color: #f4f1e8;
|
||
line-height: 1.15;
|
||
}
|
||
#title-screen .sub {
|
||
margin-top: 16px; font-family: Georgia, serif; font-style: italic;
|
||
font-size: clamp(.95rem, 2vw, 1.15rem); color: #b9b9b0;
|
||
}
|
||
#title-screen .stats {
|
||
margin-top: 34px; display: flex; gap: 28px; justify-content: center; flex-wrap: wrap;
|
||
font-family: system-ui, sans-serif;
|
||
}
|
||
#title-screen .stat { text-align: center; }
|
||
#title-screen .stat b {
|
||
display: block; font-size: 1.3rem; color: #f4f1e8; font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
#title-screen .stat span {
|
||
display: block; margin-top: 3px; font-size: .62rem; letter-spacing: .1em;
|
||
text-transform: uppercase; color: #888;
|
||
}
|
||
#title-screen .enter {
|
||
margin-top: 46px; font-family: system-ui, sans-serif; font-size: .68rem;
|
||
letter-spacing: .12em; text-transform: uppercase; color: #7fa8c9;
|
||
}
|
||
#title-screen .enter.ready { animation: pulse 2.4s ease-in-out infinite; }
|
||
@keyframes pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
||
#progress-track {
|
||
margin: 14px auto 0; width: 180px; height: 2px; background: rgba(255,255,255,.12);
|
||
border-radius: 2px; overflow: hidden;
|
||
}
|
||
#progress-bar { height: 100%; width: 0%; background: #7fa8c9; transition: width .2s ease; }
|
||
|
||
[hidden] { display: none !important; }
|
||
|
||
/* shown instead of a frozen bar: a load that dies or stalls says so, and the
|
||
retry is the reader's call — never an automatic reload, which on a phone
|
||
that just ran out of memory is exactly how the loop starts */
|
||
#falha {
|
||
position: fixed; inset: 0; z-index: 200; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center; gap: 14px; padding: 0 24px;
|
||
background: rgba(11,11,12,.94); color: #ddd; text-align: center;
|
||
font-family: system-ui, sans-serif; font-size: .9rem; line-height: 1.5;
|
||
}
|
||
#falha small { color: #888; font-size: .72rem; max-width: 34em; }
|
||
#falha button, #lento button {
|
||
font: inherit; font-size: .72rem; letter-spacing: .12em; text-transform: uppercase;
|
||
color: #0b0b0c; background: #7fa8c9; border: 0; border-radius: 3px; padding: 10px 16px;
|
||
}
|
||
#lento { margin-top: 18px; font-family: system-ui, sans-serif; font-size: .7rem; color: #999; }
|
||
#lento button { margin-top: 8px; font-size: .64rem; padding: 7px 12px; }
|
||
#aviso-leve { margin-top: 10px; font-family: system-ui, sans-serif; font-size: .62rem; color: #777; }
|
||
|
||
/* ── compact filter panel, top-left — dark chrome, so it reads as part of
|
||
the wall's own darkness instead of a lit slab thrown on top of it.
|
||
Every colour below comes from these tokens: retheme by editing them. ─── */
|
||
#filters {
|
||
--f-surface: rgba(13,13,15,.9);
|
||
--f-border: rgba(255,255,255,.14);
|
||
--f-line: rgba(255,255,255,.09);
|
||
--f-text: #eae7e0;
|
||
--f-muted: #8e8e87;
|
||
--f-faint: #6e6e68;
|
||
--f-field: rgba(255,255,255,.055);
|
||
--f-field-border: rgba(255,255,255,.16);
|
||
--f-accent: #7fa8c9;
|
||
--f-accent-soft: rgba(127,168,201,.15);
|
||
--f-accent-line: rgba(127,168,201,.34);
|
||
--f-gold: #d8b04a;
|
||
--f-danger: #d98a72;
|
||
|
||
/* right side on a desktop: the left corner is the wordmark's, and the
|
||
panel above the open card (z 20) so filtering never needs it closed */
|
||
position: fixed; top: 14px; right: 14px; z-index: 25;
|
||
width: 262px; max-width: calc(100vw - 28px);
|
||
font-family: 'Public Sans', system-ui, sans-serif;
|
||
background: var(--f-surface); border: 1px solid var(--f-border);
|
||
border-radius: 8px; color: var(--f-text);
|
||
box-shadow: 0 16px 40px rgba(0,0,0,.55);
|
||
backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
|
||
color-scheme: dark; /* native select popups, caret and scrollbars follow suit */
|
||
accent-color: var(--f-accent);
|
||
}
|
||
#filters-head {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||
padding: 9px 11px; cursor: pointer; user-select: none; border-radius: 8px;
|
||
}
|
||
#filters-head:focus-visible { outline: 2px solid var(--f-accent); outline-offset: -2px; }
|
||
#filters-head .label {
|
||
font-size: .68rem; letter-spacing: .06em; text-transform: uppercase;
|
||
color: var(--f-muted); font-weight: 700;
|
||
}
|
||
#filters-head .right { display: flex; align-items: center; gap: 7px; flex-shrink: 0; }
|
||
#filters-head .chevron { font-size: .82rem; line-height: 1; color: var(--f-gold); transition: transform .2s ease; }
|
||
#filters.open #filters-head .chevron { transform: rotate(180deg); }
|
||
/* live count of matching pieces — the panel stays informative while collapsed */
|
||
#filters-count {
|
||
font-variant-numeric: tabular-nums; background: var(--f-accent-soft); color: var(--f-accent);
|
||
border: 1px solid var(--f-accent-line);
|
||
border-radius: 3px; padding: 0 6px; font-size: .62rem; font-weight: 600;
|
||
}
|
||
#filters-count.none {
|
||
background: rgba(217,138,114,.15); color: var(--f-danger);
|
||
border-color: rgba(217,138,114,.34);
|
||
}
|
||
/* it appears the moment the first filter bites — as `hidden` it did so by
|
||
growing the header, nudging everything under it down mid-drag. It keeps
|
||
its box instead, so the panel's geometry never depends on its state. */
|
||
#filters-count.empty { visibility: hidden; }
|
||
|
||
/* ── active-filter chips: the panel's state while it is *closed*, each one
|
||
removable. Open, they are redundant (every filter is visible in its own
|
||
control) and actively harmful: a chip appearing on the first drag frame
|
||
inserted a whole row above the body and slid the slider out from under
|
||
the finger. An open panel is a fixed layout, top to bottom. ─────────── */
|
||
#filters-chips { display: flex; flex-wrap: wrap; gap: 5px; padding: 0 11px 9px; }
|
||
#filters.open #filters-chips { display: none; }
|
||
.chip {
|
||
display: inline-flex; align-items: center; gap: 3px; max-width: 100%;
|
||
font-size: .62rem; line-height: 1.7; background: var(--f-accent-soft); color: var(--f-accent);
|
||
border: 1px solid var(--f-accent-line); border-radius: 999px; padding: 0 3px 0 8px;
|
||
}
|
||
.chip .txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.chip button {
|
||
all: unset; cursor: pointer; flex-shrink: 0;
|
||
width: 14px; height: 14px; border-radius: 50%;
|
||
text-align: center; line-height: 13px; font-size: .72rem; color: var(--f-accent);
|
||
}
|
||
.chip button:hover { background: rgba(127,168,201,.28); color: #dcebf6; }
|
||
|
||
#filters-body { display: none; padding: 2px 11px 11px; border-top: 1px solid var(--f-line); }
|
||
#filters.open #filters-body { display: block; }
|
||
.f-note {
|
||
margin-top: 8px; font-size: .6rem; line-height: 1.45; color: var(--f-faint);
|
||
}
|
||
.f-row { margin-top: 11px; }
|
||
.f-row label {
|
||
display: flex; align-items: baseline; justify-content: space-between; gap: 6px;
|
||
font-size: .6rem; letter-spacing: .08em; text-transform: uppercase;
|
||
color: var(--f-muted); margin-bottom: 4px; font-weight: 600;
|
||
}
|
||
.f-row label .val {
|
||
text-transform: none; letter-spacing: 0; font-size: .66rem; font-weight: 600;
|
||
color: var(--f-text); font-variant-numeric: tabular-nums;
|
||
}
|
||
.f-row select, .f-row input[type="text"], .f-row input[type="search"] {
|
||
width: 100%; -webkit-appearance: none; appearance: none; font-family: 'Public Sans', system-ui, sans-serif; font-size: .76rem;
|
||
background: var(--f-field); color: var(--f-text); border: 1px solid var(--f-field-border);
|
||
border-radius: 5px; padding: 5px 7px; outline: none;
|
||
}
|
||
/* the popup list is painted by the OS — give it a real ground, not the
|
||
panel's translucent one, or it inherits the wall behind the field */
|
||
.f-row select { text-overflow: ellipsis; } /* long author names on a narrow phone */
|
||
.f-row option { background: #15151a; color: var(--f-text); }
|
||
.f-row input::placeholder { color: var(--f-faint); }
|
||
.f-row input[type="search"]::-webkit-search-decoration,
|
||
.f-row input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
|
||
.f-row select:focus, .f-row input:focus { border-color: var(--f-accent); }
|
||
|
||
/* text field with an inline clear button */
|
||
.f-field { position: relative; }
|
||
.f-field input { padding-right: 25px; }
|
||
.f-field .f-x {
|
||
all: unset; position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
|
||
width: 17px; height: 17px; border-radius: 50%; cursor: pointer;
|
||
text-align: center; line-height: 16px; font-size: .8rem; color: var(--f-muted);
|
||
}
|
||
.f-field .f-x:hover { background: rgba(255,255,255,.12); color: var(--f-text); }
|
||
|
||
/* ── decade histogram: distribution of the *currently* filtered set, so the
|
||
slider is aimed at something instead of guessed at. Bars are clickable. ─ */
|
||
.f-hist { display: flex; align-items: flex-end; gap: 1px; height: 28px; margin-bottom: 4px; }
|
||
.f-hist .bar {
|
||
flex: 1 1 0; min-width: 0; min-height: 2px; cursor: pointer;
|
||
background: rgba(255,255,255,.16); border-radius: 1px 1px 0 0;
|
||
transition: background .15s ease, height .2s ease;
|
||
}
|
||
.f-hist .bar.in { background: var(--f-accent); }
|
||
.f-hist .bar:hover { background: var(--f-gold); }
|
||
|
||
/* dual-handle range: two overlaid inputs, only the thumbs take pointer events */
|
||
.f-range { position: relative; height: 20px; }
|
||
.f-range-track {
|
||
position: absolute; left: 7px; right: 7px; top: 50%; transform: translateY(-50%);
|
||
height: 4px; background: rgba(255,255,255,.13); border-radius: 2px;
|
||
}
|
||
.f-range-fill { position: absolute; top: 0; bottom: 0; background: var(--f-accent); border-radius: 2px; }
|
||
.f-range input[type="range"] {
|
||
position: absolute; left: 0; top: 0; width: 100%; height: 20px; margin: 0;
|
||
-webkit-appearance: none; appearance: none; background: transparent;
|
||
pointer-events: none; outline: none; z-index: 2;
|
||
}
|
||
/* when the handles meet, the one with room to move has to be the grabbable one */
|
||
.f-range input[type="range"].top { z-index: 3; }
|
||
.f-range input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none; pointer-events: auto; cursor: grab;
|
||
width: 14px; height: 14px; border-radius: 50%; box-sizing: border-box;
|
||
background: #dfeaf2; border: 2px solid #38536b; box-shadow: 0 1px 4px rgba(0,0,0,.6);
|
||
}
|
||
.f-range input[type="range"]::-webkit-slider-thumb:active { cursor: grabbing; }
|
||
.f-range input[type="range"]::-moz-range-thumb {
|
||
pointer-events: auto; cursor: grab;
|
||
width: 14px; height: 14px; border-radius: 50%; box-sizing: border-box;
|
||
background: #dfeaf2; border: 2px solid #38536b; box-shadow: 0 1px 4px rgba(0,0,0,.6);
|
||
}
|
||
.f-range input[type="range"]::-moz-range-track { background: transparent; border: 0; }
|
||
.f-range input[type="range"]:focus-visible::-webkit-slider-thumb { border-color: var(--f-gold); }
|
||
.f-range input[type="range"]:focus-visible::-moz-range-thumb { border-color: var(--f-gold); }
|
||
|
||
#f-result {
|
||
margin-top: 12px; font-size: .66rem; color: var(--f-muted); text-align: center;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
#f-result.none { color: var(--f-danger); }
|
||
#f-clear {
|
||
all: unset; display: block; margin-top: 7px; cursor: pointer;
|
||
font-size: .66rem; letter-spacing: .04em; color: var(--f-danger); text-align: center;
|
||
padding: 5px; border-radius: 5px; border: 1px dashed var(--f-field-border);
|
||
}
|
||
#f-clear:hover { border-color: var(--f-danger); background: rgba(217,138,114,.12); }
|
||
#f-clear.disabled { opacity: .35; pointer-events: none; }
|
||
/* ── the wordmark: the one fixed thing on the wall. Same serif the title
|
||
card opens with, so the page keeps its voice after the cover lifts, and
|
||
the gold/cream pairing is the one from the eyebrow and the h1. It is
|
||
chrome, not content: it never takes a click away from the wall. ─────── */
|
||
#wordmark {
|
||
position: fixed; z-index: 26; pointer-events: none; user-select: none;
|
||
top: calc(7px + env(safe-area-inset-top)); left: 9px;
|
||
font-family: Georgia, 'Times New Roman', serif;
|
||
font-size: 1.06rem; line-height: 1.2; letter-spacing: .015em; color: #f4f1e8;
|
||
/* a sombra sozinha sumia sobre peça clara -- e peça clara é metade do
|
||
acervo (papel, álbum, anotação em nanquim). O fundo é o mesmo preto da
|
||
dica lá embaixo, e o padding recua a posição no mesmo tanto pra letra
|
||
continuar caindo onde caía. */
|
||
background: rgba(4,4,5,.92); border-radius: 4px; padding: 8px 10px 9px;
|
||
text-shadow: 0 2px 16px rgba(0,0,0,.95), 0 0 4px rgba(0,0,0,.8);
|
||
}
|
||
#wordmark .a {
|
||
display: block; font-family: 'Public Sans', system-ui, sans-serif;
|
||
font-size: .56rem; letter-spacing: .26em; text-transform: uppercase;
|
||
color: #d8b04a; margin-bottom: 3px;
|
||
}
|
||
|
||
/* the sheet's backdrop: mobile only, and only while the sheet is up */
|
||
#filters-scrim {
|
||
display: none; position: fixed; inset: 0; z-index: 24;
|
||
background: rgba(0,0,0,.45); opacity: 0; pointer-events: none;
|
||
transition: opacity .3s ease;
|
||
}
|
||
|
||
#hint {
|
||
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 10;
|
||
font-family: system-ui, sans-serif; font-size: .68rem; letter-spacing: .06em; color: #999;
|
||
background: rgba(4,4,5,.92); border-radius: 4px; padding: 6px 10px; white-space: nowrap;
|
||
pointer-events: none; transition: opacity 1s ease;
|
||
}
|
||
#hint .touch { display: none; }
|
||
@media (pointer: coarse) {
|
||
#hint .desk { display: none; }
|
||
#hint .touch { display: inline; }
|
||
}
|
||
|
||
/* ── metadata card ────────────────────────────────────────────────── */
|
||
/* The wall goes soft behind an open piece: the sharp copy is the one in the
|
||
card, and everything else steps back out of focus. */
|
||
#card-wrap {
|
||
position: fixed; inset: 0; z-index: 20;
|
||
display: flex; align-items: center; justify-content: center;
|
||
opacity: 0; pointer-events: none;
|
||
background: rgba(6,6,8,0);
|
||
backdrop-filter: blur(0px); -webkit-backdrop-filter: blur(0px);
|
||
transition: opacity .35s ease, background-color .4s ease,
|
||
backdrop-filter .4s ease, -webkit-backdrop-filter .4s ease;
|
||
}
|
||
#card-wrap.open {
|
||
opacity: 1; pointer-events: auto;
|
||
background: rgba(6,6,8,.3);
|
||
backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
|
||
}
|
||
|
||
#card {
|
||
position: relative;
|
||
width: min(420px, 90vw);
|
||
max-height: 86vh;
|
||
overflow-y: auto;
|
||
background: rgba(6,6,8,.86);
|
||
backdrop-filter: blur(6px);
|
||
border-radius: 6px;
|
||
padding: 34px 30px 26px;
|
||
text-align: center;
|
||
box-shadow: 0 30px 80px rgba(0,0,0,.6);
|
||
transform: translateY(10px) scale(.97);
|
||
transition: transform .35s ease;
|
||
}
|
||
#card-wrap.open #card { transform: translateY(0) scale(1); }
|
||
|
||
#card-back {
|
||
position: absolute; left: 14px; top: 14px;
|
||
width: 30px; height: 30px; border-radius: 50%;
|
||
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.18);
|
||
color: #ddd; font-size: 1rem; line-height: 28px; text-align: center;
|
||
cursor: pointer; font-family: system-ui, sans-serif;
|
||
}
|
||
#card-back:hover { background: rgba(255,255,255,.18); }
|
||
|
||
#card-type { font-size: 1.55rem; font-weight: 400; color: #f2f2f2; margin-bottom: 6px; }
|
||
#card-seealso { font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; color: #7fa8c9; margin-bottom: 18px; }
|
||
#card-seealso span { color: #cdd8e0; }
|
||
|
||
#card-img-wrap { margin: 0 auto 18px; max-width: 260px; }
|
||
#card-img {
|
||
display: block; width: 100%; height: auto; border-radius: 2px;
|
||
box-shadow: 0 12px 40px rgba(0,0,0,.55);
|
||
image-rendering: auto;
|
||
}
|
||
|
||
#card-title { font-size: 1.02rem; color: #f5f5f0; }
|
||
#card-date { font-size: .85rem; color: #b9b9b0; margin-top: 2px; }
|
||
#card-contrib { font-size: .82rem; color: #cdd8e0; margin-top: 10px; }
|
||
#card-tags { margin-top: 12px; font-family: system-ui, sans-serif; font-size: .7rem; line-height: 1.55; color: #b8c6d1; }
|
||
#card-tags:empty { display: none; }
|
||
#card-tags .tn { margin: 0; padding: 0 0 0 13px; border-left: 1px solid rgba(127,168,201,.28); }
|
||
#card-tags > .tn { padding-left: 0; border-left: 0; }
|
||
#card-tags .tl { padding: 1px 0; overflow-wrap: anywhere; }
|
||
#card-tags .tn.place > .tl { color: #dcebf6; font-weight: 600; }
|
||
#card-tags .tn.topic > .tl { color: #b8c6d1; }
|
||
#card-tags .tn > .tl::before { content: '└ '; color: rgba(127,168,201,.5); }
|
||
#card-tags > .tn > .tl::before { content: ''; }
|
||
#card-tags .to { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||
#card-tags .to span {
|
||
font-size: .64rem; line-height: 1.7; padding: 0 8px; border-radius: 999px;
|
||
background: rgba(127,168,201,.12); border: 1px solid rgba(127,168,201,.22);
|
||
}
|
||
#card-footer {
|
||
margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.12);
|
||
font-family: system-ui, sans-serif; font-size: .68rem; letter-spacing: .04em; color: #888;
|
||
}
|
||
#card-footer a { color: #d8b04a; text-decoration: none; }
|
||
#card-footer a:hover { text-decoration: underline; }
|
||
#card-rights { margin-top: 6px; font-size: .68rem; color: #777; }
|
||
|
||
/* stepping between pieces without a keyboard */
|
||
#card-nav { display: flex; gap: 9px; margin-top: 16px; }
|
||
#card-nav button {
|
||
all: unset; flex: 1 1 0; box-sizing: border-box; cursor: pointer; text-align: center;
|
||
font-family: system-ui, sans-serif; font-size: .7rem; letter-spacing: .05em; color: #cdd8e0;
|
||
background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14);
|
||
border-radius: 6px; padding: 9px 6px;
|
||
}
|
||
#card-nav button:hover { background: rgba(255,255,255,.13); }
|
||
#card-nav button:active { background: rgba(255,255,255,.2); }
|
||
|
||
/* ── small screens / fingers ─────────────────────────────────────────────
|
||
Two things change shape here: the filter panel spans the width instead
|
||
of crowding a corner, and the card becomes a bottom sheet so the piece
|
||
you just tapped stays visible on the strip of wall above it. Every hit
|
||
target is grown to something a thumb can actually land on. ─────────── */
|
||
@media (max-width: 640px) {
|
||
#title-screen .stats { gap: 18px; }
|
||
#title-screen .enter { margin-top: 34px; }
|
||
|
||
/* The panel becomes what a phone expects at the bottom of the screen: a
|
||
sheet parked on the edge showing only its handle, its title and whatever
|
||
filters are active, which rises when you pull it up. It also puts the
|
||
controls where the thumb already is, instead of at the top of the screen.
|
||
--sheet-peek (measured in JS: the body's offset inside the panel) is how
|
||
much of it stays visible while it is down. */
|
||
#filters {
|
||
left: 0; right: 0; width: auto; max-width: none;
|
||
top: auto; bottom: 0;
|
||
border-radius: 16px 16px 0 0;
|
||
border-left: 0; border-right: 0; border-bottom: 0;
|
||
box-shadow: 0 -14px 44px rgba(0,0,0,.6);
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
transform: translateY(var(--sheet-hide, 340px));
|
||
transition: transform .34s cubic-bezier(.32, .72, 0, 1);
|
||
touch-action: none; /* dragging the sheet is never a page scroll */
|
||
}
|
||
#filters.open { transform: translateY(0); }
|
||
#filters::before { /* grab handle — the sheet reads as draggable before it is touched */
|
||
content: ''; display: block; width: 40px; height: 4px; margin: 9px auto 1px;
|
||
border-radius: 2px; background: rgba(255,255,255,.24);
|
||
}
|
||
#filters-scrim { display: block; }
|
||
#filters-scrim.on { opacity: 1; pointer-events: auto; }
|
||
|
||
#filters-head { padding: 11px 14px 12px; }
|
||
#filters-head .label { font-size: .72rem; }
|
||
/* the chevron means "this way": up while the sheet is parked, down once
|
||
it is up. Anchored at the bottom, the desktop sense of it is backwards. */
|
||
#filters-head .chevron { transform: rotate(180deg); }
|
||
#filters.open #filters-head .chevron { transform: rotate(0deg); }
|
||
#filters-count { font-size: .66rem; padding: 2px 7px; }
|
||
/* the transform does the hiding, so the body is always laid out — that is
|
||
what makes the peek measurable and the slide animate */
|
||
#filters-body {
|
||
display: block; padding-left: 14px; padding-right: 14px;
|
||
max-height: min(64dvh, 540px); overflow-y: auto; overscroll-behavior: contain;
|
||
-webkit-overflow-scrolling: touch; padding-bottom: 14px;
|
||
touch-action: pan-y;
|
||
}
|
||
.f-row select, .f-row input { font-size: 16px; padding: 8px 9px; } /* 16px: no iOS focus-zoom */
|
||
.f-field input { padding-right: 34px; }
|
||
.f-field .f-x { width: 26px; height: 26px; line-height: 25px; right: 4px; font-size: 1rem; }
|
||
#filters-chips { gap: 6px; padding-bottom: 11px; }
|
||
.chip { font-size: .7rem; line-height: 2; padding: 0 4px 0 10px; }
|
||
.chip button { width: 19px; height: 19px; line-height: 18px; font-size: .82rem; }
|
||
.f-hist { height: 34px; gap: 2px; }
|
||
.f-range { height: 32px; }
|
||
.f-range input[type="range"] { height: 32px; }
|
||
.f-range input[type="range"]::-webkit-slider-thumb { width: 22px; height: 22px; }
|
||
.f-range input[type="range"]::-moz-range-thumb { width: 22px; height: 22px; }
|
||
.f-range-track { left: 11px; right: 11px; }
|
||
#f-result { font-size: .72rem; }
|
||
#f-clear { width: 100%; padding: 9px; font-size: .72rem; }
|
||
|
||
#hint {
|
||
left: 8px; right: 8px; transform: none; text-align: center; white-space: normal;
|
||
bottom: calc(var(--sheet-peek, 56px) + 12px + env(safe-area-inset-bottom));
|
||
font-size: .66rem; line-height: 1.5;
|
||
}
|
||
|
||
#wordmark { font-size: .96rem; left: 6px; }
|
||
|
||
/* both are sheets on the same edge, and the filter sheet now sits above the
|
||
card: park the card on top of its peek instead of under it, or the peek
|
||
would cover the card's own ‹ anterior / próxima › */
|
||
#card-wrap {
|
||
align-items: flex-end;
|
||
padding-bottom: calc(var(--sheet-peek, 56px) + env(safe-area-inset-bottom));
|
||
}
|
||
#card {
|
||
/* the sheet is capped short of the screen on purpose: the band of wall it
|
||
leaves above is where the opened piece goes, and at 86dvh there was no
|
||
band — the piece rose straight behind the sheet */
|
||
width: 100%; max-width: none; max-height: min(66dvh, 560px);
|
||
overscroll-behavior: contain;
|
||
border-radius: 16px 16px 0 0; background: rgba(6,6,8,.94);
|
||
padding: 12px 18px calc(16px + env(safe-area-inset-bottom));
|
||
transform: translateY(26px) scale(1);
|
||
}
|
||
#card::before { /* grab handle — says "sheet", and it can be swiped */
|
||
content: ''; display: block; width: 40px; height: 4px; margin: 0 auto 12px;
|
||
border-radius: 2px; background: rgba(255,255,255,.24);
|
||
}
|
||
#card-back { top: 8px; left: 8px; width: 34px; height: 34px; line-height: 32px; }
|
||
#card-type { font-size: 1.3rem; }
|
||
#card-seealso { margin-bottom: 14px; }
|
||
#card-img-wrap { max-width: min(210px, 48vw); margin-bottom: 14px; }
|
||
#card-nav button { padding: 12px 6px; font-size: .74rem; }
|
||
}
|
||
|
||
@media (max-width: 380px) {
|
||
#card { padding-left: 14px; padding-right: 14px; }
|
||
#card-img-wrap { max-width: 165px; }
|
||
}
|
||
|
||
/* a phone held sideways: a full-height sheet would swallow the screen */
|
||
@media (max-height: 460px) and (max-width: 900px) {
|
||
#card-wrap { align-items: center; }
|
||
#card {
|
||
width: min(460px, 92vw); max-height: 92dvh; border-radius: 8px;
|
||
padding: 12px 18px 14px;
|
||
}
|
||
#card::before { display: none; }
|
||
#card-img-wrap { max-width: 150px; margin-bottom: 10px; }
|
||
#filters-body { max-height: 58dvh; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<canvas id="canvas"></canvas>
|
||
|
||
<div id="loading">
|
||
<p id="loading-msg">carregando mural…</p>
|
||
<div id="title-screen">
|
||
<div class="eyebrow">Biblioteca Nacional · Acervo Digital</div>
|
||
<h1>Brasiliana Fotográfica</h1>
|
||
<p class="sub">um mural do acervo fotográfico brasileiro</p>
|
||
<div class="stats">
|
||
<div class="stat"><b id="stat-pecas">—</b><span>peças</span></div>
|
||
<div class="stat"><b id="stat-periodo">—</b><span>período</span></div>
|
||
<div class="stat"><b id="stat-instituicoes">—</b><span>instituições</span></div>
|
||
</div>
|
||
<div class="enter" id="enter-label">carregando imagens… 0%</div>
|
||
<div id="progress-track"><div id="progress-bar"></div></div>
|
||
<div id="aviso-leve" hidden>modo leve — a tentativa anterior não terminou de carregar</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="wordmark"><span class="a">Acervo</span>Brasiliana</div>
|
||
|
||
<div id="filters-scrim" aria-hidden="true"></div>
|
||
<div id="filters">
|
||
<div id="filters-head" role="button" tabindex="0" aria-expanded="false" aria-controls="filters-body">
|
||
<span class="label">filtros</span>
|
||
<span class="right">
|
||
<span id="filters-count" class="empty"></span>
|
||
<span class="chevron" aria-hidden="true">▾</span>
|
||
</span>
|
||
</div>
|
||
<div id="filters-chips" hidden></div>
|
||
<div id="filters-body">
|
||
<p class="f-note" id="filters-label">carregando…</p>
|
||
<div class="f-row">
|
||
<label for="f-search">Busca livre</label>
|
||
<div class="f-field">
|
||
<input type="search" id="f-search" autocomplete="off" placeholder="qualquer palavra dos metadados…">
|
||
<button type="button" class="f-x" id="f-search-x" aria-label="limpar busca" hidden>×</button>
|
||
</div>
|
||
</div>
|
||
<div class="f-row">
|
||
<label for="f-institution">Instituição</label>
|
||
<select id="f-institution"><option value="">todas</option></select>
|
||
</div>
|
||
<div class="f-row">
|
||
<label for="f-year-lo"><span>Década</span><span class="val" id="f-year-readout">—</span></label>
|
||
<div class="f-hist" id="f-hist"></div>
|
||
<div class="f-range">
|
||
<div class="f-range-track"><div class="f-range-fill" id="f-year-fill"></div></div>
|
||
<input type="range" id="f-year-lo" aria-label="década inicial">
|
||
<input type="range" id="f-year-hi" aria-label="década final">
|
||
</div>
|
||
</div>
|
||
<div class="f-row">
|
||
<label for="f-author">Autor / fotógrafo</label>
|
||
<select id="f-author"><option value="">todos</option></select>
|
||
</div>
|
||
<div class="f-row">
|
||
<label for="f-subject">Assunto</label>
|
||
<select id="f-subject"><option value="">todos</option></select>
|
||
</div>
|
||
<div class="f-row">
|
||
<label for="f-type">Tipo de suporte</label>
|
||
<select id="f-type"><option value="">todos</option></select>
|
||
</div>
|
||
<div id="f-result">—</div>
|
||
<button id="f-clear" class="disabled">limpar filtros</button>
|
||
</div>
|
||
</div>
|
||
<p id="hint">
|
||
<span class="desk">arraste para deslocar · scroll para aproximar · clique numa peça · setas navegam com a ficha aberta · <b style="font-weight:600;color:#bbb">f</b> filtros · <b style="font-weight:600;color:#bbb">/</b> busca</span>
|
||
<span class="touch">arraste para deslocar · pinça para aproximar · toque numa peça</span>
|
||
</p>
|
||
|
||
<div id="card-wrap">
|
||
<div id="card">
|
||
<div id="card-back">←</div>
|
||
<div id="card-type">—</div>
|
||
<div id="card-seealso"><span id="card-community">—</span> · <span id="card-date-tag">—</span></div>
|
||
<div id="card-img-wrap"><img id="card-img" alt=""></div>
|
||
<div id="card-title">—</div>
|
||
<div id="card-date">—</div>
|
||
<div id="card-contrib"></div>
|
||
<div id="card-tags"></div>
|
||
<div id="card-footer">
|
||
<div><a id="card-link" href="#" target="_blank" rel="noopener">ver item original ↗</a> · <span id="card-handle"></span></div>
|
||
<div id="card-rights"></div>
|
||
</div>
|
||
<div id="card-nav">
|
||
<button type="button" id="card-prev" aria-label="peça anterior">‹ anterior</button>
|
||
<button type="button" id="card-next" aria-label="próxima peça">próxima ›</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
|
||
const isMobile = window.matchMedia('(max-width: 640px)').matches;
|
||
const isTouch = window.matchMedia('(pointer: coarse)').matches;
|
||
|
||
// ─── renderer / scene / camera ─────────────────────────────────────────────
|
||
const canvas = document.getElementById('canvas');
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: !isTouch, powerPreference: 'high-performance' });
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isTouch ? 1.75 : 2));
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
|
||
// ─── light mode and the crash-loop guard ──────────────────────────────────
|
||
// On an iPhone, running out of memory throws nothing: the system kills the tab
|
||
// and reloads it by itself, the page starts the very same load, dies again — a
|
||
// loop. A mark in sessionStorage survives that automatic reload, so the next
|
||
// attempt knows the last one never finished and goes straight to the light
|
||
// atlas instead of repeating the crash.
|
||
const LOAD_MARK = 'mural_carregando';
|
||
const store = (kind, fn) => { try { return fn(window[kind]); } catch (e) { return null; } };
|
||
const previousLoadDied = !!store('sessionStorage', s => s.getItem(LOAD_MARK));
|
||
const lightMode = isTouch || previousLoadDied
|
||
|| store('localStorage', s => s.getItem('mural_leve')) === '1'
|
||
|| renderer.capabilities.maxTextureSize < 8192
|
||
|| (navigator.deviceMemory !== undefined && navigator.deviceMemory <= 4);
|
||
|
||
function showFailure(title, detail) {
|
||
if (document.getElementById('falha')) return;
|
||
const box = document.createElement('div');
|
||
box.id = 'falha';
|
||
const h = document.createElement('div'); h.textContent = title;
|
||
const small = document.createElement('small');
|
||
small.textContent = detail || 'Tentar de novo carrega a versão leve do mural, feita para celular.';
|
||
const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'tentar de novo';
|
||
btn.addEventListener('click', () => {
|
||
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now()))); // retry in light mode
|
||
location.reload();
|
||
});
|
||
box.append(h, small, btn);
|
||
document.body.appendChild(box);
|
||
}
|
||
|
||
// the GPU dropping the wall's textures is a memory signal too — say so, and let
|
||
// the reader decide; preventDefault keeps the browser from giving up silently
|
||
canvas.addEventListener('webglcontextlost', e => {
|
||
e.preventDefault();
|
||
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
|
||
showFailure('O navegador liberou a memória do mural.');
|
||
}, false);
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x0b0b0c);
|
||
|
||
const START_Z = isMobile ? 17 : 26;
|
||
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 500);
|
||
camera.position.set(0, 0, START_Z);
|
||
|
||
function resize() {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
}
|
||
new ResizeObserver(resize).observe(document.documentElement);
|
||
// iOS collapses/expands its URL bar without resizing the documentElement
|
||
window.visualViewport?.addEventListener('resize', resize);
|
||
window.addEventListener('orientationchange', () => setTimeout(resize, 250));
|
||
|
||
// ─── pan / zoom over the flat wall (no orbit — it's a wall, not a globe) ───
|
||
// the camera itself never moves on selection — only the tiles do (see selectTile below)
|
||
// one pointer pans, two pinch to zoom; the wheel keeps doing the desktop job.
|
||
// upper bound generous enough to fit the whole wall (thousands of tiles,
|
||
// ~180 units wide) in view at once, well under the camera's far plane (500)
|
||
const MIN_Z = 4, MAX_Z = 400;
|
||
const camTarget = new THREE.Vector3(0, 0, START_Z); // camera eases toward this each frame
|
||
|
||
// world units per screen pixel at a given distance — panning then tracks the
|
||
// finger exactly at every zoom level instead of racing away when zoomed in
|
||
// (at the default distance this works out to the old fixed 0.03 factor)
|
||
const HALF_FOV_TAN = Math.tan(THREE.MathUtils.degToRad(50 / 2));
|
||
const worldPerPx = (z = camTarget.z) => (2 * z * HALF_FOV_TAN) / window.innerHeight;
|
||
const visibleHeight = (z = camTarget.z) => 2 * z * HALF_FOV_TAN;
|
||
|
||
renderer.domElement.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
if (selected) return; // navigation is frozen while a card is open
|
||
// multiplicative, not additive: a notch of the wheel covers the same *share*
|
||
// of the distance at every zoom, instead of crawling far out and lurching close
|
||
camTarget.z = THREE.MathUtils.clamp(camTarget.z * Math.exp(e.deltaY * 0.0011), MIN_Z, MAX_Z);
|
||
}, { passive: false });
|
||
|
||
const pointers = new Map(); // live pointers on the canvas, by pointerId
|
||
let gesture = null; // camera + finger state sampled when the set changed
|
||
let multi = false; // this gesture was ever a pinch → the last lift isn't a tap
|
||
|
||
function centroid() {
|
||
let x = 0, y = 0;
|
||
for (const p of pointers.values()) { x += p.x; y += p.y; }
|
||
return { x: x / pointers.size, y: y / pointers.size };
|
||
}
|
||
function spread() {
|
||
const [a, b] = [...pointers.values()];
|
||
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
|
||
}
|
||
// re-baselined whenever a finger lands or lifts, so the view never jumps
|
||
function rebase() {
|
||
const c = centroid();
|
||
gesture = {
|
||
cx: c.x, cy: c.y,
|
||
camX: camTarget.x, camY: camTarget.y, camZ: camTarget.z,
|
||
spread: pointers.size > 1 ? spread() : 0,
|
||
k: worldPerPx(),
|
||
};
|
||
}
|
||
|
||
/* ─── the throw ──────────────────────────────────────────────────────────────
|
||
A drag that ends while still moving keeps going and slows like something with
|
||
weight, instead of stopping dead the instant the finger leaves the glass.
|
||
`vel` is the live speed of the drag, smoothed; `glide` is what it was thrown
|
||
with, decaying every frame after the release. Both are world units a second,
|
||
so a throw covers the same amount of wall at any zoom. */
|
||
const glide = new THREE.Vector2();
|
||
const vel = new THREE.Vector2();
|
||
let lastMoveAt = 0;
|
||
const GLIDE_TAU = 0.42; // how long the coast lasts
|
||
const GLIDE_MAX = 90; // ceiling on a flick, in world units a second
|
||
|
||
renderer.domElement.addEventListener('pointerdown', e => {
|
||
if (selected) return; // navigation is frozen while a card is open
|
||
renderer.domElement.setPointerCapture?.(e.pointerId);
|
||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, ox: e.clientX, oy: e.clientY, moved: false });
|
||
if (pointers.size > 1) multi = true;
|
||
glide.set(0, 0); vel.set(0, 0); // a finger on the glass stops the wall dead
|
||
lastMoveAt = performance.now();
|
||
rebase();
|
||
});
|
||
|
||
renderer.domElement.addEventListener('pointermove', e => {
|
||
const p = pointers.get(e.pointerId);
|
||
if (!p || !gesture) return;
|
||
p.x = e.clientX; p.y = e.clientY;
|
||
// a finger wobbles far more than a mouse does on a click
|
||
if (Math.hypot(p.x - p.ox, p.y - p.oy) > (e.pointerType === 'mouse' ? 4 : 12)) p.moved = true;
|
||
|
||
if (pointers.size > 1) camTarget.z = THREE.MathUtils.clamp(gesture.camZ * (gesture.spread / spread()), MIN_Z, MAX_Z);
|
||
const c = centroid();
|
||
const k = pointers.size > 1 ? worldPerPx() : gesture.k; // pinching also drags the midpoint
|
||
const px = camTarget.x, py = camTarget.y;
|
||
camTarget.x = gesture.camX - (c.x - gesture.cx) * k;
|
||
camTarget.y = gesture.camY + (c.y - gesture.cy) * k;
|
||
|
||
// speed of the drag itself, smoothed — one jittery sample must not decide
|
||
// where the wall gets thrown, and a stale one must not survive a pause
|
||
const t = performance.now();
|
||
const dt = (t - lastMoveAt) / 1000;
|
||
lastMoveAt = t;
|
||
if (dt > 0 && dt < 0.1) {
|
||
vel.x += ((camTarget.x - px) / dt - vel.x) * 0.35;
|
||
vel.y += ((camTarget.y - py) / dt - vel.y) * 0.35;
|
||
} else vel.set(0, 0);
|
||
});
|
||
|
||
function endPointer(e) {
|
||
const p = pointers.get(e.pointerId);
|
||
if (!p) return;
|
||
pointers.delete(e.pointerId);
|
||
if (pointers.size) { rebase(); return; } // other fingers still down
|
||
if (!p.moved && !multi && e.type === 'pointerup') handleTap(e.clientX, e.clientY, e.pointerType !== 'mouse');
|
||
// a finger that came to rest before lifting is holding the wall, not throwing it
|
||
else if (e.type === 'pointerup' && performance.now() - lastMoveAt < 90) {
|
||
glide.copy(vel).clampLength(0, GLIDE_MAX);
|
||
}
|
||
vel.set(0, 0);
|
||
multi = false;
|
||
gesture = null;
|
||
}
|
||
window.addEventListener('pointerup', endPointer);
|
||
window.addEventListener('pointercancel', endPointer);
|
||
|
||
// on a phone the open panel covers most of the wall — a tap on the wall means
|
||
// "let me see it", so the first tap puts the panel away rather than selecting
|
||
function handleTap(x, y, touch) {
|
||
if (isMobile && filtersEl.classList.contains('open')) { toggleFilters(false); return; }
|
||
doRaycast(x, y, touch);
|
||
}
|
||
|
||
// ─── grid of tiles ──────────────────────────────────────────────────────────
|
||
// A mesh per piece meant 12.552 draw calls a frame, every one of them a
|
||
// transparent object the renderer had to depth-sort first — which is what made
|
||
// zooming out stutter: the further you pull back, the more of them are on
|
||
// screen. Instead there is one InstancedMesh per atlas page (four in all), and
|
||
// a tile is a plain object holding its own animation state. Draw calls no
|
||
// longer depend on the zoom level.
|
||
const tileMeshes = []; // Tile[], in grid order — arrow navigation walks this
|
||
const pages = []; // one per atlas page: { mesh, tiles, dims, dimAttr, dirty }
|
||
const pageMeshes = []; // the raycast targets, kept as a stable array
|
||
const animating = new Set(); // tiles still easing toward their target
|
||
const tileGroup = new THREE.Group();
|
||
scene.add(tileGroup);
|
||
|
||
const GUTTER = 1.12; // spacing multiplier so tiles don't touch
|
||
let GRID_COLS = 1; // set by buildGrid, used by arrow-key navigation to move by row/col
|
||
|
||
// every tile is the same quad — only its instance matrix, atlas offset and
|
||
// dimming differ, so the geometry is built once and shared
|
||
const tileGeo = new THREE.PlaneGeometry(1, 1);
|
||
const placeholderMat = new THREE.MeshBasicMaterial({ color: 0x1a1a1a });
|
||
|
||
// MeshBasicMaterial knows nothing about per-instance atlas windows or dimming,
|
||
// so both are fed in as instanced attributes and patched into its shader
|
||
function tileMaterial(tex, tpr) {
|
||
const mat = new THREE.MeshBasicMaterial({ map: tex });
|
||
mat.onBeforeCompile = shader => {
|
||
shader.uniforms.uUvScale = { value: 1 / tpr };
|
||
shader.vertexShader = `
|
||
attribute vec2 aUv;
|
||
attribute float aDim;
|
||
uniform float uUvScale;
|
||
varying float vDim;
|
||
` + shader.vertexShader.replace(
|
||
'#include <uv_vertex>',
|
||
'vMapUv = uv * uUvScale + aUv;\n vDim = aDim;'
|
||
);
|
||
// the dim factor is a perceptual one (0.07 reads as "nearly gone"), but it
|
||
// lands in linear space — decode it or a dimmed wall comes out four times
|
||
// brighter than the old alpha blend did
|
||
shader.fragmentShader = 'varying float vDim;\n' + shader.fragmentShader.replace(
|
||
'#include <map_fragment>',
|
||
'#include <map_fragment>\n diffuseColor.rgb *= pow( vDim, 2.2 );'
|
||
);
|
||
};
|
||
return mat;
|
||
}
|
||
|
||
function buildGrid(handles, mapEntries, tpr, nPages) {
|
||
const n = handles.length;
|
||
const cols = Math.max(1, Math.round(Math.sqrt(n * (window.innerWidth / window.innerHeight) * 1.4)));
|
||
const rows = Math.ceil(n / cols);
|
||
GRID_COLS = cols;
|
||
|
||
const counts = new Array(nPages).fill(0);
|
||
handles.forEach(h => counts[mapEntries[h].atlas || 0]++);
|
||
|
||
for (let i = 0; i < nPages; i++) {
|
||
const count = Math.max(1, counts[i]);
|
||
const geo = tileGeo.clone(); // the quad is shared; the per-instance attributes are not
|
||
const uvAttr = new THREE.InstancedBufferAttribute(new Float32Array(count * 2), 2);
|
||
const dims = new Float32Array(count).fill(1);
|
||
const dimAttr = new THREE.InstancedBufferAttribute(dims, 1);
|
||
dimAttr.setUsage(THREE.DynamicDrawUsage);
|
||
geo.setAttribute('aUv', uvAttr);
|
||
geo.setAttribute('aDim', dimAttr);
|
||
|
||
const mesh = new THREE.InstancedMesh(geo, placeholderMat, count);
|
||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||
mesh.frustumCulled = false; // the wall's bounds shift as tiles animate, and four objects cost nothing
|
||
mesh.userData.page = i;
|
||
tileGroup.add(mesh);
|
||
pages.push({ mesh, tiles: [], dims, dimAttr, dirty: false });
|
||
pageMeshes.push(mesh);
|
||
}
|
||
|
||
const m4 = new THREE.Matrix4();
|
||
handles.forEach((handle, i) => {
|
||
const col = i % cols, row = Math.floor(i / cols);
|
||
const entry = mapEntries[handle];
|
||
const page = pages[entry.atlas || 0];
|
||
const iid = page.tiles.length;
|
||
|
||
const pos = new THREE.Vector3(
|
||
(col - cols / 2) * GUTTER,
|
||
(rows / 2 - row) * GUTTER,
|
||
(Math.random() - 0.5) * 0.6 // slight depth jitter, like the reference photo
|
||
);
|
||
const tile = {
|
||
page: entry.atlas || 0, iid,
|
||
pos: pos.clone(), scl: 1, dim: 1,
|
||
userData: {
|
||
handle, meta: entry.meta,
|
||
basePos: pos.clone(),
|
||
targetPos: pos.clone(),
|
||
targetScale: 1,
|
||
targetOpacity: 1, // dimmed (not hidden) by the filter panel — keeps the grid layout intact
|
||
},
|
||
};
|
||
|
||
// the tile's window into the atlas, as a uv offset off the shared quad
|
||
page.mesh.geometry.attributes.aUv.setXY(iid, entry.col / tpr, 1 - (entry.row + 1) / tpr);
|
||
m4.makeScale(1, 1, 1).setPosition(pos);
|
||
page.mesh.setMatrixAt(iid, m4);
|
||
|
||
page.tiles.push(tile);
|
||
tileMeshes.push(tile);
|
||
tileByHandle.set(handle, tile);
|
||
});
|
||
|
||
pages.forEach(p => { p.mesh.instanceMatrix.needsUpdate = true; p.mesh.geometry.attributes.aUv.needsUpdate = true; });
|
||
return { cols, rows };
|
||
}
|
||
|
||
// a tile only costs anything on the frames where it is actually moving
|
||
function markAnimating(tile) { animating.add(tile); }
|
||
|
||
const _m4 = new THREE.Matrix4();
|
||
// the tiles ease on the same clock the camera does: 0.16 a frame *was* the
|
||
// right weight at 60 Hz, and twice that on a 120 Hz phone
|
||
const TAU_TILE = 0.096;
|
||
function updateTileAnimation(dt) {
|
||
if (!animating.size) return;
|
||
const k = ease(dt, TAU_TILE);
|
||
for (const t of animating) {
|
||
const d = t.userData;
|
||
let settled = true;
|
||
if (t.pos.distanceToSquared(d.targetPos) > 1e-6) { t.pos.lerp(d.targetPos, k); settled = false; }
|
||
else t.pos.copy(d.targetPos);
|
||
if (Math.abs(t.scl - d.targetScale) > 1e-4) { t.scl += (d.targetScale - t.scl) * k; settled = false; }
|
||
else t.scl = d.targetScale;
|
||
if (Math.abs(t.dim - d.targetOpacity) > 1e-4) { t.dim += (d.targetOpacity - t.dim) * k; settled = false; }
|
||
else t.dim = d.targetOpacity;
|
||
|
||
const page = pages[t.page];
|
||
_m4.makeScale(t.scl, t.scl, 1).setPosition(t.pos);
|
||
page.mesh.setMatrixAt(t.iid, _m4);
|
||
page.dims[t.iid] = t.dim;
|
||
page.dirty = true;
|
||
if (settled) animating.delete(t);
|
||
}
|
||
for (const page of pages) {
|
||
if (!page.dirty) continue;
|
||
page.mesh.instanceMatrix.needsUpdate = true;
|
||
page.dimAttr.needsUpdate = true;
|
||
page.dirty = false;
|
||
}
|
||
}
|
||
|
||
// ─── raycasting ─────────────────────────────────────────────────────────────
|
||
const raycaster = new THREE.Raycaster();
|
||
const pointer = new THREE.Vector2();
|
||
let hovered = null;
|
||
|
||
// dimmed-out tiles are inert: they can't be hovered, clicked or stepped onto,
|
||
// so an active filter behaves like a real subset and not just a paint job
|
||
function firstLiveHit() {
|
||
const hits = raycaster.intersectObjects(pageMeshes, false);
|
||
for (const h of hits) {
|
||
const tile = pages[h.object.userData.page].tiles[h.instanceId];
|
||
if (tile && !tile.userData.filteredOut) return tile;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// the canvas' own box, not the window: on iOS the two drift apart whenever the
|
||
// URL bar slides and the picked point lands a tile or two off the finger
|
||
function pickAt(cx, cy) {
|
||
const r = renderer.domElement.getBoundingClientRect();
|
||
pointer.x = ((cx - r.left) / r.width) * 2 - 1;
|
||
pointer.y = -((cy - r.top) / r.height) * 2 + 1;
|
||
raycaster.setFromCamera(pointer, camera);
|
||
return firstLiveHit();
|
||
}
|
||
|
||
// A fingertip covers far more wall than a tile does at the opening zoom (a tile
|
||
// is ~50 px there), so a tap that looks dead-on often lands in a gutter. On
|
||
// touch, sweep a small ring around the point before calling it a miss.
|
||
const TAP_RING = [[0, 0], [0, -11], [11, 0], [0, 11], [-11, 0], [-8, -8], [8, -8], [8, 8], [-8, 8]];
|
||
function doRaycast(cx, cy, touch) {
|
||
for (const [ox, oy] of (touch ? TAP_RING : [[0, 0]])) {
|
||
const m = pickAt(cx + ox, cy + oy);
|
||
if (m) { openTile(m); return; }
|
||
}
|
||
}
|
||
|
||
renderer.domElement.addEventListener('pointermove', e => {
|
||
if (e.pointerType !== 'mouse' || pointers.size || selected) return; // no hover state on a finger
|
||
const m = pickAt(e.clientX, e.clientY);
|
||
if (m !== hovered) {
|
||
if (hovered) { hovered.userData.targetScale = 1; markAnimating(hovered); }
|
||
hovered = m;
|
||
if (hovered) { hovered.userData.targetScale = 1.08; markAnimating(hovered); }
|
||
renderer.domElement.style.cursor = m ? 'pointer' : '';
|
||
}
|
||
});
|
||
|
||
// ─── selection / card ─────────────────────────────────────────────────────
|
||
// the camera never moves here — the *tiles* do: the clicked one grows and
|
||
// moves to screen-center, the rest part radially away from that same point
|
||
// (the "hole" always opens in the middle, wherever you clicked).
|
||
let atlasSources = []; // { source, size } per atlas page — used to crop the tile into the card canvas
|
||
let atlasEntries = null, TPR = 1;
|
||
let selected = null;
|
||
let selectedAt = 0;
|
||
|
||
// Where the opened piece goes and how big it gets. On a desktop that is simply
|
||
// the middle of the screen; on a phone the card is a bottom sheet, so the piece
|
||
// is centred in whatever band of wall is left above it — measured off the sheet
|
||
// that was just filled in, because its height depends on how much metadata the
|
||
// item carries. Guessed at a fixed fraction, the piece ended up behind the sheet.
|
||
function framing(visH) {
|
||
if (!isMobile) return { lift: 0, scale: 3.4 };
|
||
const vh = window.innerHeight;
|
||
// Where the wall begins depends on where the filter panel is anchored: as a
|
||
// bar at the top it is the line under its header (open or closed — a tap on
|
||
// the wall closes the rest of it); as a sheet on the bottom edge it takes
|
||
// nothing off the top at all, and it sits behind the card either way.
|
||
const fr = filtersEl.getBoundingClientRect();
|
||
const top = (fr.bottom < vh - 1 ? fr.top + filtersHead.offsetHeight : 0) + 10;
|
||
// the card is parked above the filter sheet's peek, so the wall ends where
|
||
// the card's own laid-out top is — not at the bottom of the screen
|
||
const pad = parseFloat(getComputedStyle(cardEl.parentElement).paddingBottom) || 0;
|
||
const bottom = vh - cardEl.offsetHeight - pad - 10;
|
||
const band = bottom - top;
|
||
if (band < 70) return { lift: 0, scale: Math.min(3.4, visH * 0.18) }; // no room to speak of
|
||
return {
|
||
lift: (vh / 2 - (top + bottom) / 2) / vh * visH,
|
||
scale: THREE.MathUtils.clamp((band / vh) * visH * 0.82, 1.2, 3.4),
|
||
};
|
||
}
|
||
|
||
function selectTile(mesh) {
|
||
closing = false;
|
||
selected = mesh;
|
||
selectedAt = performance.now();
|
||
hovered = null;
|
||
|
||
showCard(mesh.userData); // fill the card first: its height decides the framing
|
||
|
||
const centerZ = mesh.userData.basePos.z;
|
||
const cx = camTarget.x, cy = camTarget.y;
|
||
const visH = visibleHeight();
|
||
const { lift, scale } = framing(visH);
|
||
|
||
// Beyond the edge of the screen the parting is invisible, and moving every
|
||
// one of twelve thousand instances for it costs a phone the whole animation.
|
||
const reach = Math.hypot(visH * camera.aspect, visH) * 0.5 + 8;
|
||
|
||
tileMeshes.forEach(m => {
|
||
if (m === mesh) return;
|
||
const dx = m.userData.basePos.x - cx;
|
||
const dy = m.userData.basePos.y - cy;
|
||
const dist = Math.hypot(dx, dy) || 0.001;
|
||
if (dist > reach) { rest(m); return; }
|
||
const ux = dx / dist, uy = dy / dist;
|
||
const push = THREE.MathUtils.clamp(6.5 - dist * 0.5, 1.5, 6.5); // closer tiles get shoved further
|
||
aim(m, m.userData.basePos.clone().add(new THREE.Vector3(ux * push, uy * push, -1.5)), 1);
|
||
});
|
||
|
||
aim(mesh, new THREE.Vector3(cx, cy + lift, centerZ + 4), scale);
|
||
}
|
||
|
||
// a tile only joins the animation set when it has somewhere to go
|
||
function aim(m, pos, scale) {
|
||
m.userData.targetPos = pos;
|
||
m.userData.targetScale = scale;
|
||
if (m.pos.distanceToSquared(pos) > 1e-6 || Math.abs(m.scl - scale) > 1e-4) markAnimating(m);
|
||
}
|
||
function rest(m) { aim(m, m.userData.basePos, 1); }
|
||
|
||
function deselect() {
|
||
closing = false;
|
||
tileMeshes.forEach(rest);
|
||
document.getElementById('card-wrap').classList.remove('open');
|
||
selected = null;
|
||
}
|
||
document.getElementById('card-back').addEventListener('click', () => closeCard());
|
||
// A tap is pointerup *then* a compatibility click. The card opens on the
|
||
// pointerup, so that click lands on whatever the card just put under the
|
||
// finger — the backdrop (closing it again) or a button on the sheet. Swallow
|
||
// anything that arrives within a moment of the card opening.
|
||
// stopImmediatePropagation, not stopPropagation: when the click lands on the
|
||
// backdrop itself the closing listener below is on that same element, and
|
||
// plain stopPropagation does not reach listeners on the target node — so every
|
||
// tap that opened a card in the upper half of a phone screen closed it again.
|
||
document.getElementById('card-wrap').addEventListener('click', e => {
|
||
if (performance.now() - selectedAt < 400) { e.stopImmediatePropagation(); e.preventDefault(); }
|
||
}, true);
|
||
document.getElementById('card-wrap').addEventListener('click', e => {
|
||
if (e.target.id === 'card-wrap') closeCard();
|
||
});
|
||
// the wall is frozen while a piece is up, so a scroll out there means "give it
|
||
// back" — the card folds away and the wheel that followed does the zooming
|
||
document.getElementById('card-wrap').addEventListener('wheel', e => {
|
||
if (selected && !e.target.closest('#card')) closeCard(); // the card's own body still scrolls
|
||
}, { passive: true });
|
||
|
||
/* ─── browser history ────────────────────────────────────────────────────────
|
||
Opening a piece is an event worth going back from: each one becomes a history
|
||
entry carrying its handle, so the phone's back gesture (and the browser's own
|
||
back button) closes the card instead of leaving the wall, and back/forward
|
||
retrace the pieces you opened — including the ones you stepped to with
|
||
‹ anterior / próxima ›. The handle also lands in the URL, so a piece can be
|
||
linked to and re-opened straight from the address bar.
|
||
Everything that changes the view goes through openTile/closeCard; selectTile
|
||
and deselect are the raw moves, used as-is when *history* is the one asking. */
|
||
const tileByHandle = new Map(); // filled in buildGrid
|
||
let historyReady = false; // no entries before the wall can answer for them
|
||
|
||
function openTile(tile) {
|
||
selectTile(tile);
|
||
if (!historyReady) return;
|
||
const h = tile.userData.handle;
|
||
// holding an arrow key walks the wall faster than Safari allows entries to be
|
||
// pushed (100 per 30s); the wall keeps working, it just stops recording
|
||
try { history.pushState({ h }, '', `#${encodeURIComponent(h)}`); } catch {}
|
||
}
|
||
|
||
let closing = false; // a history.back() already asked for; popstate clears it
|
||
|
||
function closeCard() {
|
||
if (closing) return; // dragging a filter fires apply() per frame — one back, not twelve
|
||
// stepping back is what put the card on screen — unwind it the same way, so
|
||
// forward can bring the piece back instead of a dead entry
|
||
if (historyReady && history.state && history.state.h) { closing = true; history.back(); return; }
|
||
deselect();
|
||
}
|
||
|
||
const handleFromHash = () => {
|
||
try { return decodeURIComponent(location.hash.slice(1)); } catch { return ''; }
|
||
};
|
||
|
||
window.addEventListener('popstate', e => {
|
||
if (!historyReady) return;
|
||
const h = (e.state && e.state.h) || handleFromHash();
|
||
const tile = h ? tileByHandle.get(h) : null;
|
||
if (tile) { if (tile !== selected) selectTile(tile); }
|
||
else if (selected) deselect();
|
||
});
|
||
const filtersEl = document.getElementById('filters');
|
||
const filtersHead = document.getElementById('filters-head');
|
||
const filtersScrim = document.getElementById('filters-scrim');
|
||
const filtersBodyEl = document.getElementById('filters-body');
|
||
|
||
function toggleFilters(force) {
|
||
const open = force !== undefined ? force : !filtersEl.classList.contains('open');
|
||
filtersEl.classList.toggle('open', open);
|
||
filtersHead.setAttribute('aria-expanded', String(open));
|
||
filtersScrim.classList.toggle('on', open && isMobile);
|
||
}
|
||
filtersScrim.addEventListener('click', () => toggleFilters(false));
|
||
|
||
// ── the mobile sheet ──────────────────────────────────────────────────────
|
||
// Parked on the bottom edge it shows only its handle, title and active chips;
|
||
// everything below that is translated off-screen. Both distances are measured
|
||
// rather than guessed, because the chips change the panel's height as filters
|
||
// come and go.
|
||
let sheetHide = 340; // px of panel hanging below the screen while it is down
|
||
function measureSheet() {
|
||
const peek = filtersBodyEl.offsetTop; // handle + head + chips
|
||
sheetHide = Math.max(0, filtersEl.offsetHeight - peek);
|
||
const root = document.documentElement.style;
|
||
root.setProperty('--sheet-peek', `${peek}px`); // the hint clears this
|
||
root.setProperty('--sheet-hide', `${sheetHide}px`);
|
||
}
|
||
if (isMobile) {
|
||
measureSheet();
|
||
// the chips row appears and disappears with the filters, changing the peek
|
||
new ResizeObserver(measureSheet).observe(filtersEl);
|
||
window.addEventListener('orientationchange', () => setTimeout(measureSheet, 260));
|
||
}
|
||
|
||
// drag the handle to raise or drop it, following the finger the whole way
|
||
let sheetDrag = null;
|
||
filtersEl.addEventListener('touchstart', e => {
|
||
if (!isMobile || e.touches.length !== 1) return;
|
||
if (e.target.closest('#filters-body')) return; // that area scrolls and holds controls
|
||
sheetDrag = {
|
||
y: e.touches[0].clientY, moved: 0,
|
||
base: filtersEl.classList.contains('open') ? 0 : sheetHide,
|
||
};
|
||
filtersEl.style.transition = 'none';
|
||
}, { passive: true });
|
||
window.addEventListener('touchmove', e => {
|
||
if (!sheetDrag || !e.touches.length) return;
|
||
sheetDrag.moved = e.touches[0].clientY - sheetDrag.y;
|
||
const y = Math.min(sheetHide, Math.max(0, sheetDrag.base + sheetDrag.moved));
|
||
filtersEl.style.transform = `translateY(${y}px)`;
|
||
}, { passive: true });
|
||
function endSheetDrag() {
|
||
if (!sheetDrag) return;
|
||
const { moved } = sheetDrag;
|
||
sheetDrag = null;
|
||
filtersEl.style.transition = '';
|
||
filtersEl.style.transform = ''; // back to whatever the open/closed class says
|
||
if (Math.abs(moved) > 40) toggleFilters(moved < 0); // up opens, down closes
|
||
}
|
||
window.addEventListener('touchend', endSheetDrag);
|
||
window.addEventListener('touchcancel', endSheetDrag);
|
||
|
||
window.addEventListener('keydown', e => {
|
||
const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
|
||
if (e.key === 'Escape') {
|
||
if (selected) closeCard();
|
||
else if (typing) e.target.blur();
|
||
else toggleFilters(false);
|
||
return;
|
||
}
|
||
if (e.key === 'f' && !typing) { toggleFilters(); return; }
|
||
if (e.key === '/' && !typing) { // straight into the search box
|
||
e.preventDefault();
|
||
toggleFilters(true);
|
||
document.getElementById('f-search').focus();
|
||
return;
|
||
}
|
||
if (!selected || typing) return; // arrow navigation only makes sense with a card open
|
||
// move by actual row/col in the grid, not raw array index — Up/Down jump
|
||
// a full row (GRID_COLS), Left/Right move one column, both wrap at the ends.
|
||
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: GRID_COLS, ArrowUp: -GRID_COLS }[e.key];
|
||
if (!step) return;
|
||
e.preventDefault();
|
||
stepSelection(step);
|
||
});
|
||
|
||
// filtered-out tiles are skipped over, so stepping stays inside the subset
|
||
function stepSelection(step) {
|
||
if (!selected) return;
|
||
const n = tileMeshes.length;
|
||
let i = tileMeshes.indexOf(selected);
|
||
for (let k = 0; k < n; k++) {
|
||
i = (i + step + n) % n;
|
||
if (!tileMeshes[i].userData.filteredOut) { openTile(tileMeshes[i]); return; }
|
||
}
|
||
}
|
||
|
||
// the same step, reachable without a keyboard
|
||
document.getElementById('card-prev').addEventListener('click', () => stepSelection(-1));
|
||
document.getElementById('card-next').addEventListener('click', () => stepSelection(1));
|
||
|
||
// ...and by swiping the card sideways. Vertical stays free for scrolling the
|
||
// card's own body, and a downward flick on the sheet closes it.
|
||
const cardEl = document.getElementById('card');
|
||
let swipe = null;
|
||
cardEl.addEventListener('touchstart', e => {
|
||
swipe = e.touches.length === 1 ? { x: e.touches[0].clientX, y: e.touches[0].clientY, top: cardEl.scrollTop } : null;
|
||
}, { passive: true });
|
||
cardEl.addEventListener('touchend', e => {
|
||
if (!swipe || !e.changedTouches.length) return;
|
||
const dx = e.changedTouches[0].clientX - swipe.x;
|
||
const dy = e.changedTouches[0].clientY - swipe.y;
|
||
swipe = null;
|
||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.6) stepSelection(dx < 0 ? 1 : -1);
|
||
else if (dy > 90 && Math.abs(dy) > Math.abs(dx) * 1.6 && cardEl.scrollTop <= 0) closeCard();
|
||
}, { passive: true });
|
||
|
||
// Every item's page is the same address with its handle in it, so the map used
|
||
// to spend 1,2 MB repeating that prefix 12.552 times — and meta.handle another
|
||
// 0,4 MB repeating the key it was already filed under. Both are derived here.
|
||
const sourceUrl = h => `https://brasilianafotografica.bn.gov.br/brasiliana/handle/${h}?show=full`;
|
||
|
||
function showCard(data) {
|
||
const meta = data.meta || {};
|
||
document.getElementById('card-type').textContent = meta.type || 'Fotografia';
|
||
document.getElementById('card-community').textContent = meta.community || '—';
|
||
document.getElementById('card-date-tag').textContent = meta.date || 's.d.';
|
||
document.getElementById('card-title').textContent = meta.title || '(sem título)';
|
||
document.getElementById('card-date').textContent = meta.date || '';
|
||
document.getElementById('card-contrib').textContent = meta.contributor ? meta.contributor : '';
|
||
// this piece's dc.subject, one chip each (accent/case duplicates folded)
|
||
const tagsEl = document.getElementById('card-tags');
|
||
renderSubjectTree(tagsEl, meta);
|
||
document.getElementById('card-handle').textContent = data.handle || '';
|
||
document.getElementById('card-link').href = data.handle ? sourceUrl(data.handle) : '#';
|
||
document.getElementById('card-rights').textContent = meta.rights ? `Direitos: ${meta.rights}` : '';
|
||
|
||
// crop this tile out of the atlas image for a (still small, but framed) preview
|
||
const entry = atlasEntries[data.handle];
|
||
const img = document.getElementById('card-img');
|
||
const page = atlasSources[entry?.atlas || 0];
|
||
if (page && entry) {
|
||
const S = page.size / TPR;
|
||
const out = Math.min(512, Math.round(S * 4)); // no point upscaling past 4x
|
||
const c = document.createElement('canvas');
|
||
c.width = c.height = out;
|
||
const ctx = c.getContext('2d');
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.drawImage(page.source, entry.col * S, entry.row * S, S, S, 0, 0, out, out);
|
||
img.src = c.toDataURL('image/jpeg', 0.92);
|
||
}
|
||
document.getElementById('card-wrap').classList.add('open');
|
||
}
|
||
|
||
// ─── render loop ────────────────────────────────────────────────────────────
|
||
// Easing is a time constant here, never a per-frame fraction: a fixed fraction
|
||
// converges twice as fast at 120 Hz as at 60, so the wall had a different weight
|
||
// on every screen. `tau` is roughly how long it takes to cover most of the gap.
|
||
const ease = (dt, tau) => 1 - Math.exp(-dt / tau);
|
||
const TAU_GRAB = 0.035; // finger down — the wall stays under the finger
|
||
const TAU_FREE = 0.10; // let go — it settles
|
||
const TAU_ZOOM = 0.12;
|
||
let lastFrame = performance.now();
|
||
|
||
function tick(now = lastFrame) {
|
||
requestAnimationFrame(tick);
|
||
// a backgrounded tab comes back with a gap of seconds: clamp it, or the wall
|
||
// teleports on the first frame after the user returns
|
||
const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000));
|
||
lastFrame = now;
|
||
|
||
// the coast, before the camera reads its target
|
||
if (!pointers.size && !selected && glide.lengthSq() > 0.04) {
|
||
camTarget.x += glide.x * dt;
|
||
camTarget.y += glide.y * dt;
|
||
glide.multiplyScalar(Math.exp(-dt / GLIDE_TAU));
|
||
} else if (!pointers.size) glide.set(0, 0);
|
||
|
||
// pure user pan/zoom — never touched by selection
|
||
const kxy = ease(dt, pointers.size ? TAU_GRAB : TAU_FREE);
|
||
camera.position.x += (camTarget.x - camera.position.x) * kxy;
|
||
camera.position.y += (camTarget.y - camera.position.y) * kxy;
|
||
camera.position.z += (camTarget.z - camera.position.z) * ease(dt, TAU_ZOOM);
|
||
// no lookAt: the camera stays square to the wall. Aiming it at the *target*
|
||
// while it trailed behind tilted the whole view a little on every drag —
|
||
// which is what made a fast pan swim instead of slide.
|
||
|
||
updateTileAnimation(dt);
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
// ─── filter panel: instituição / década / autor / tipo ─────────────────────
|
||
// tiles that don't match are dimmed (targetOpacity), never removed — the
|
||
// grid layout stays intact so navigation and the atlas UVs never shift.
|
||
filtersHead.addEventListener('click', () => toggleFilters());
|
||
filtersHead.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleFilters(); }
|
||
});
|
||
|
||
// pt-BR catalogue text is full of accents and nobody types them into a filter
|
||
const deburr = str => str.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||
|
||
// everything a record carries, flattened into one string — naming the fields
|
||
// would quietly drop whatever the catalogue gains later
|
||
function flattenMeta(value) {
|
||
if (value == null) return '';
|
||
if (Array.isArray(value)) return value.map(flattenMeta).join(' ');
|
||
if (typeof value === 'object') return Object.values(value).map(flattenMeta).join(' ');
|
||
return String(value);
|
||
}
|
||
|
||
function yearOf(meta) {
|
||
const m = String((meta || {}).date || '').match(/(1[5-9]\d{2}|20\d{2})/);
|
||
return m ? parseInt(m[1], 10) : null;
|
||
}
|
||
|
||
// ─── filter-value cleanup: the catalogue's type / author strings are messy ───
|
||
// (OCR debris, case variants, life dates glued on, "Nome Sobrenome" vs "Sobrenome, Nome").
|
||
// Only the filter lists and matching use these; the card still shows the raw record.
|
||
const KNOWN_TYPES = ['Fotografia/ Papel', 'Negativo/ Vidro', 'Diapositivo/ Vidro', 'Reprodução Fotomecânica/ Papel', 'Negativo flexível'];
|
||
function cleanType(raw) {
|
||
const s = String(raw || '').replace(/\s+/g, ' ').trim();
|
||
if (!s) return '';
|
||
const fold = deburr(s);
|
||
const known = KNOWN_TYPES.find(k => deburr(k) === fold);
|
||
if (known) return known;
|
||
// OCR debris: rebuild from the tail that survived ("…afia/ Papel", "…gativo/ Vidro")
|
||
if (/(?:rafia|FOa)\/ Papel$/.test(s)) return 'Fotografia/ Papel';
|
||
if (/gativo\/ Vidro$/.test(s)) return 'Negativo/ Vidro';
|
||
if (/Fotomec/.test(s)) return 'Reprodução Fotomecânica/ Papel';
|
||
if (/[<>{}\d]/.test(s)) return ''; // unrecoverable garbage — leave it out of the list
|
||
return /[A-ZÀ-Ý]{3,}/.test(s) ? s.charAt(0) + s.slice(1).toLowerCase() : s; // "NEGATIVO flexível" → "Negativo flexível"
|
||
}
|
||
|
||
const NO_AUTHOR = /^(s\/autor|s\/nome|s\/a|\[?s\.?n\.?\]?|sem autor|\[sem autor\]|autoria n[aã]o identificada|autor n[aã]o identificado|n[aã]o identificado|an[oô]nimo)$/i;
|
||
function cleanAuthor(raw) {
|
||
let s = String(raw || '').replace(/\s+/g, ' ').trim();
|
||
s = s.replace(/^Fot[óo]grafo:\s*/i, '')
|
||
.replace(/\s*[-–]?\s*\((?:autoria|fot[óo]grafo|firma)\)/gi, '')
|
||
.replace(/\s+-\s+(?:Fot[óo]grafo|Est[úu]dio\/Ag[eê]ncia)$/i, '');
|
||
// life dates / floruit glued to the end: ", 1864-1957", "(1893 - 1961)", "[? -1908]", "18- -19-"
|
||
const tail = /[,\s]*[\[(]?\s*(?:(?:ca\.|fl\.|m\.|n\.)\s*)?(?:\?|\d{2,4}-?)\s*(?:[-–]\s*(?:ca\.\s*)?\d{0,4})?\s*[\])]?[.,\s]*$/i;
|
||
for (let i = 0; i < 3 && tail.test(s) && s.replace(tail, '').length > 2; i++) s = s.replace(tail, '');
|
||
s = s.replace(/[.,\s]+$/, '').replace(/^\[(.*)\]$/, '$1').trim();
|
||
if (!s || NO_AUTHOR.test(s) || /[<>]|\d{3}/.test(s)) return '';
|
||
return s;
|
||
}
|
||
// same person, different word order or accents → same key
|
||
const authorKey = name => deburr(name).replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(Boolean).sort().join(' ');
|
||
|
||
// dc.subject folds accents and case; the select shows only tags that recur, since
|
||
// ~8000 distinct values (57% used once) would be an unusable list
|
||
const subjectKey = t => deburr(String(t)).replace(/\s+/g, ' ').trim();
|
||
const SUBJECT_MIN = 20;
|
||
|
||
// the catalogue repeats most subjects in English ("Railroads - Buildings and
|
||
// structures - Porto Velho (Rondônia, Brazil)" beside "Ferrovias - …"); keep the
|
||
// Portuguese side. An English-only record keeps what it has rather than none.
|
||
const EN_WORD = new RegExp('\\b(?:brazil|state|buildings?|structures|and|of|the|railroads?|rivers?|bridges|streets|church(?:es)?|photographs?|public|dwellings|portraits?|stations?|construction|region|waterfalls|plazas|mountains|women|history|life|hospitals?|views?|colon(?:y|ies)|slaves|panoramic|aerial|harbors?|ports?|forts?|islands?|beaches|houses|schools?|palaces?|squares?|paraguay|amazon|falls|lakes?|people|children|boats|ships|trains?|roads?|cities|city|towns?|landscapes?|factories|industry)\\b', 'i');
|
||
function subjectsOf(meta) {
|
||
const seen = new Set(), all = [];
|
||
[].concat((meta || {}).subject || []).forEach(t => {
|
||
const k = subjectKey(t);
|
||
if (k && !seen.has(k)) { seen.add(k); all.push(String(t).trim()); }
|
||
});
|
||
const pt = all.filter(t => !EN_WORD.test(t));
|
||
return pt.length ? pt : all;
|
||
}
|
||
|
||
// Brasil › estado › cidade › tópico, derived from the tag text: "Tópico - Cidade (UF)"
|
||
// carries the whole path. Tags without a recognisable place stay out of the tree.
|
||
const UF = { AC: 'Acre', AL: 'Alagoas', AP: 'Amapá', AM: 'Amazonas', BA: 'Bahia', CE: 'Ceará', DF: 'Distrito Federal',
|
||
ES: 'Espírito Santo', GO: 'Goiás', MA: 'Maranhão', MT: 'Mato Grosso', MS: 'Mato Grosso do Sul', MG: 'Minas Gerais',
|
||
PA: 'Pará', PB: 'Paraíba', PR: 'Paraná', PE: 'Pernambuco', PI: 'Piauí', RJ: 'Rio de Janeiro', RN: 'Rio Grande do Norte',
|
||
RS: 'Rio Grande do Sul', RO: 'Rondônia', RR: 'Roraima', SC: 'Santa Catarina', SP: 'São Paulo', SE: 'Sergipe', TO: 'Tocantins' };
|
||
const STATE_BY_KEY = new Map(Object.values(UF).map(n => [deburr(n), n]));
|
||
|
||
function subjectTree(meta) {
|
||
const root = { kids: new Map() };
|
||
const put = path => path.reduce((n, label) => {
|
||
if (!n.kids.has(label)) n.kids.set(label, { kids: new Map(), place: false });
|
||
return n.kids.get(label);
|
||
}, root);
|
||
const others = [];
|
||
let hasPlace = false;
|
||
subjectsOf(meta).forEach(tag => {
|
||
const key = deburr(tag).replace(/\s*\(estado\)$/, '');
|
||
if (key === 'brasil') { put(['Brasil']).place = true; hasPlace = true; return; }
|
||
if (STATE_BY_KEY.has(key)) {
|
||
const st = STATE_BY_KEY.get(key);
|
||
put(['Brasil']).place = true; put(['Brasil', st]).place = true; hasPlace = true; return;
|
||
}
|
||
const parts = tag.split(/\s+-\s+/);
|
||
// "Espírito Santo (Estado) - Colônias": a state carrying a topic
|
||
const sm = parts[0].match(/^(.+?)\s*\(Estado\)$/i);
|
||
if (sm && STATE_BY_KEY.has(deburr(sm[1]))) {
|
||
const path = ['Brasil', STATE_BY_KEY.get(deburr(sm[1]))];
|
||
path.forEach((_, i) => { put(path.slice(0, i + 1)).place = true; });
|
||
parts.slice(1).forEach((_, i) => put(path.concat(parts.slice(1, i + 2))));
|
||
hasPlace = true; return;
|
||
}
|
||
const m = parts[parts.length - 1].match(/^(.+?)\s*\(([A-Z]{2})\)$/);
|
||
if (!m || !UF[m[2]]) { others.push(tag); return; }
|
||
const path = ['Brasil', UF[m[2]], m[1]];
|
||
path.slice(0, 3).forEach((_, i) => { put(path.slice(0, i + 1)).place = true; });
|
||
parts.slice(0, -1).forEach((_, i) => { put(path.concat(parts.slice(0, i + 1))); });
|
||
hasPlace = true;
|
||
});
|
||
// a lone "Brasil" is no tree — it reads better as a chip with the rest
|
||
if (hasPlace && root.kids.get('Brasil').kids.size === 0) { root.kids.clear(); others.push('Brasil'); }
|
||
return { root, others };
|
||
}
|
||
|
||
function renderSubjectTree(el, meta) {
|
||
el.replaceChildren();
|
||
const { root, others } = subjectTree(meta);
|
||
const draw = (node, label, into) => {
|
||
const d = document.createElement('div');
|
||
d.className = 'tn ' + (node.place ? 'place' : 'topic');
|
||
const l = document.createElement('div'); l.className = 'tl'; l.textContent = label;
|
||
d.appendChild(l);
|
||
node.kids.forEach((kid, name) => draw(kid, name, d));
|
||
into.appendChild(d);
|
||
};
|
||
root.kids.forEach((kid, name) => draw(kid, name, el));
|
||
if (others.length) {
|
||
const o = document.createElement('div'); o.className = 'to';
|
||
others.forEach(t => { const c = document.createElement('span'); c.textContent = t; o.appendChild(c); });
|
||
el.appendChild(o);
|
||
}
|
||
}
|
||
|
||
function setupFilters(mapData) {
|
||
const entries = Object.values(mapData.map);
|
||
const instCounts = new Map(), typeCounts = new Map(), authorGroups = new Map(), subjectGroups = new Map();
|
||
let minYear = Infinity, maxYear = -Infinity;
|
||
|
||
entries.forEach(e => {
|
||
const meta = e.meta || {};
|
||
if (meta.community) instCounts.set(meta.community, (instCounts.get(meta.community) || 0) + 1);
|
||
const t = cleanType(meta.type);
|
||
if (t) typeCounts.set(t, (typeCounts.get(t) || 0) + 1);
|
||
subjectsOf(meta).map(subjectKey).forEach(k => {
|
||
subjectGroups.set(k, (subjectGroups.get(k) || 0) + 1);
|
||
});
|
||
const a = cleanAuthor(meta.contributor);
|
||
if (a) {
|
||
const k = authorKey(a), g = authorGroups.get(k) || { n: 0, forms: new Map() };
|
||
g.n++; g.forms.set(a, (g.forms.get(a) || 0) + 1);
|
||
authorGroups.set(k, g);
|
||
}
|
||
const y = yearOf(meta);
|
||
if (y != null) { if (y < minYear) minYear = y; if (y > maxYear) maxYear = y; }
|
||
});
|
||
if (minYear > maxYear) { minYear = 1860; maxYear = 1930; } // no dated items — shouldn't happen, but stay safe
|
||
|
||
const selInst = document.getElementById('f-institution');
|
||
[...instCounts.entries()].sort((a, b) => b[1] - a[1]).forEach(([name, n]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = name; opt.textContent = `${name} (${n.toLocaleString('pt-BR')})`;
|
||
selInst.appendChild(opt);
|
||
});
|
||
|
||
const selType = document.getElementById('f-type');
|
||
[...typeCounts.entries()].sort((a, b) => b[1] - a[1]).forEach(([name, n]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = name; opt.textContent = `${name.replace('/', ' / ')} (${n.toLocaleString('pt-BR')})`;
|
||
selType.appendChild(opt);
|
||
});
|
||
|
||
// one entry per person: the most common spelling names the group, and
|
||
// "Sobrenome, Nome" wins a tie over "Nome Sobrenome"
|
||
const authorNames = new Map();
|
||
const selAuthor = document.getElementById('f-author');
|
||
[...authorGroups.entries()].sort((a, b) => b[1].n - a[1].n).forEach(([key, g]) => {
|
||
const name = [...g.forms.entries()].sort((a, b) => b[1] - a[1] || b[0].includes(',') - a[0].includes(','))[0][0];
|
||
authorNames.set(key, name);
|
||
const opt = document.createElement('option');
|
||
opt.value = key; opt.textContent = `${name} (${g.n.toLocaleString('pt-BR')})`;
|
||
selAuthor.appendChild(opt);
|
||
});
|
||
|
||
const subjectNames = new Map(); // key → a readable spelling
|
||
entries.forEach(e => subjectsOf(e.meta).forEach(t => {
|
||
const k = subjectKey(t);
|
||
if (k && subjectGroups.get(k) >= SUBJECT_MIN && !subjectNames.has(k)) subjectNames.set(k, String(t).trim());
|
||
}));
|
||
const selSubject = document.getElementById('f-subject');
|
||
[...subjectGroups.entries()].filter(([, n]) => n >= SUBJECT_MIN).sort((a, b) => b[1] - a[1]).forEach(([k, n]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = k; opt.textContent = `${subjectNames.get(k)} (${n.toLocaleString('pt-BR')})`;
|
||
selSubject.appendChild(opt);
|
||
});
|
||
|
||
const decadeMin = Math.floor(minYear / 10) * 10;
|
||
const decadeMax = Math.floor(maxYear / 10) * 10;
|
||
const nDecades = (decadeMax - decadeMin) / 10 + 1;
|
||
const span = Math.max(1, decadeMax - decadeMin); // guards the single-decade case
|
||
|
||
const loSlider = document.getElementById('f-year-lo');
|
||
const hiSlider = document.getElementById('f-year-hi');
|
||
loSlider.min = hiSlider.min = decadeMin;
|
||
loSlider.max = hiSlider.max = decadeMax;
|
||
loSlider.step = hiSlider.step = 10;
|
||
loSlider.value = decadeMin;
|
||
hiSlider.value = decadeMax;
|
||
|
||
const yearReadout = document.getElementById('f-year-readout');
|
||
const yearFill = document.getElementById('f-year-fill');
|
||
const histEl = document.getElementById('f-hist');
|
||
const searchInput = document.getElementById('f-search');
|
||
const searchX = document.getElementById('f-search-x');
|
||
const clearBtn = document.getElementById('f-clear');
|
||
const countBadge = document.getElementById('filters-count');
|
||
const chipsEl = document.getElementById('filters-chips');
|
||
const resultEl = document.getElementById('f-result');
|
||
|
||
// parse each tile's year and fold its text once — apply() runs on every
|
||
// keystroke and drag frame, so it must stay free of string work
|
||
tileMeshes.forEach(m => {
|
||
const meta = m.userData.meta || {};
|
||
m.userData._year = yearOf(meta);
|
||
const a = cleanAuthor(meta.contributor);
|
||
m.userData._author = a ? authorKey(a) : '';
|
||
m.userData._type = cleanType(meta.type);
|
||
m.userData._subjects = new Set(subjectsOf(meta).map(subjectKey));
|
||
// one haystack per piece: every metadata field, not a chosen few
|
||
// the handle is the map's key, not a field of meta — it still has to be
|
||
// searchable (people paste a handle or its number straight into the box)
|
||
m.userData._hay = deburr(m.userData.handle + ' ' + flattenMeta(meta));
|
||
});
|
||
|
||
const bars = [];
|
||
for (let i = 0; i < nDecades; i++) {
|
||
const bar = document.createElement('div');
|
||
bar.className = 'bar';
|
||
const dec = decadeMin + i * 10;
|
||
// click a bar to isolate that decade; click it again to release the range
|
||
bar.addEventListener('click', () => {
|
||
const alone = state.yearTouched && state.yearLo === dec && state.yearHi === dec;
|
||
if (alone) { resetYear(); } else {
|
||
loSlider.value = hiSlider.value = dec;
|
||
state.yearLo = state.yearHi = dec; state.yearTouched = true;
|
||
}
|
||
apply();
|
||
});
|
||
histEl.appendChild(bar);
|
||
bars.push(bar);
|
||
}
|
||
|
||
const state = { search: '', institution: '', yearLo: decadeMin, yearHi: decadeMax, yearTouched: false, author: '', type: '', subject: '' };
|
||
|
||
function resetYear() {
|
||
state.yearTouched = false;
|
||
state.yearLo = decadeMin; state.yearHi = decadeMax;
|
||
loSlider.value = decadeMin; hiSlider.value = decadeMax;
|
||
}
|
||
|
||
function apply() {
|
||
// free text is AND across terms, so "rio 1900" narrows instead of widening
|
||
const terms = deburr(state.search.trim()).split(/\s+/).filter(Boolean);
|
||
const decCounts = new Array(nDecades).fill(0);
|
||
let shown = 0;
|
||
|
||
tileMeshes.forEach(m => {
|
||
const d = m.userData, meta = d.meta || {};
|
||
// everything except the decade — this is what the histogram counts, so the
|
||
// bars show what each decade *would* give under the other filters
|
||
const okRest =
|
||
(!state.institution || meta.community === state.institution) &&
|
||
(!state.type || d._type === state.type) &&
|
||
(!state.author || d._author === state.author) &&
|
||
(!state.subject || d._subjects.has(state.subject)) &&
|
||
terms.every(t => d._hay.includes(t));
|
||
if (okRest && d._year != null) {
|
||
const i = Math.floor((d._year - decadeMin) / 10);
|
||
if (i >= 0 && i < nDecades) decCounts[i]++;
|
||
}
|
||
const okYear = !state.yearTouched ||
|
||
(d._year != null && d._year >= state.yearLo && d._year <= state.yearHi + 9);
|
||
const ok = okRest && okYear;
|
||
d.filteredOut = !ok;
|
||
if (d.targetOpacity !== (ok ? 1 : 0.07)) { d.targetOpacity = ok ? 1 : 0.07; markAnimating(m); }
|
||
if (ok) shown++;
|
||
});
|
||
|
||
const peak = Math.max(1, ...decCounts);
|
||
bars.forEach((bar, i) => {
|
||
const dec = decadeMin + i * 10;
|
||
// sqrt scale — a linear one flattens every decade next to the 1900s peak
|
||
bar.style.height = `${2 + 26 * Math.sqrt(decCounts[i] / peak)}px`;
|
||
bar.classList.toggle('in', !state.yearTouched || (dec >= state.yearLo && dec <= state.yearHi));
|
||
bar.title = `${dec}s · ${decCounts[i].toLocaleString('pt-BR')} peças`;
|
||
});
|
||
|
||
const lo = state.yearTouched ? state.yearLo : decadeMin;
|
||
const hi = state.yearTouched ? state.yearHi : decadeMax;
|
||
yearFill.style.left = `${((lo - decadeMin) / span) * 100}%`;
|
||
yearFill.style.right = `${((decadeMax - hi) / span) * 100}%`;
|
||
yearReadout.textContent = yearLabel(lo, hi);
|
||
loSlider.classList.toggle('top', lo > (decadeMin + decadeMax) / 2);
|
||
|
||
const active = activeFilters();
|
||
renderChips(active);
|
||
searchX.hidden = !state.search;
|
||
clearBtn.classList.toggle('disabled', active.length === 0);
|
||
|
||
countBadge.classList.toggle('empty', active.length === 0);
|
||
countBadge.textContent = shown.toLocaleString('pt-BR');
|
||
countBadge.classList.toggle('none', shown === 0);
|
||
resultEl.classList.toggle('none', shown === 0);
|
||
resultEl.textContent = shown === 0
|
||
? 'nenhuma peça corresponde'
|
||
: active.length === 0
|
||
? `${shown.toLocaleString('pt-BR')} peças com imagem`
|
||
: `${shown.toLocaleString('pt-BR')} de ${tileMeshes.length.toLocaleString('pt-BR')} peças`;
|
||
|
||
// the subset just changed under the open piece — fold the card away so the
|
||
// new wall is the thing being looked at (and so a piece that no longer
|
||
// matches is not left sitting on top of a wall that excludes it)
|
||
if (selected) closeCard();
|
||
}
|
||
|
||
// the last decade is usually a stub (the wall ends in 2022, not 2029), so the
|
||
// range label stops at the newest year actually in the acervo
|
||
function yearLabel(lo, hi) { return `${lo}–${Math.min(hi + 9, maxYear)}`; }
|
||
|
||
function activeFilters() {
|
||
const out = [];
|
||
if (state.search.trim()) out.push({ key: 'search', text: `“${state.search.trim()}”` });
|
||
if (state.institution) out.push({ key: 'institution', text: state.institution });
|
||
if (state.yearTouched) out.push({ key: 'year', text: yearLabel(state.yearLo, state.yearHi) });
|
||
if (state.author) out.push({ key: 'author', text: authorNames.get(state.author) });
|
||
if (state.subject) out.push({ key: 'subject', text: subjectNames.get(state.subject) });
|
||
if (state.type) out.push({ key: 'type', text: state.type });
|
||
return out;
|
||
}
|
||
|
||
function clearOne(key) {
|
||
if (key === 'search') { state.search = ''; searchInput.value = ''; }
|
||
if (key === 'institution') { state.institution = ''; selInst.value = ''; }
|
||
if (key === 'type') { state.type = ''; selType.value = ''; }
|
||
if (key === 'subject') { state.subject = ''; selSubject.value = ''; }
|
||
if (key === 'author') { state.author = ''; selAuthor.value = ''; }
|
||
if (key === 'year') resetYear();
|
||
apply();
|
||
}
|
||
|
||
function renderChips(active) {
|
||
chipsEl.hidden = active.length === 0;
|
||
chipsEl.textContent = '';
|
||
active.forEach(({ key, text }) => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'chip';
|
||
const label = document.createElement('span');
|
||
label.className = 'txt';
|
||
label.textContent = text; // catalogue metadata — never as markup
|
||
chip.title = text;
|
||
const x = document.createElement('button');
|
||
x.type = 'button';
|
||
x.textContent = '×';
|
||
x.setAttribute('aria-label', `remover filtro ${text}`);
|
||
x.addEventListener('click', e => { e.stopPropagation(); clearOne(key); });
|
||
chip.append(label, x);
|
||
chipsEl.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
selInst.addEventListener('change', () => { state.institution = selInst.value; apply(); });
|
||
selType.addEventListener('change', () => { state.type = selType.value; apply(); });
|
||
|
||
// typing re-tests every tile, so hold off until the burst settles
|
||
let searchTimer = null;
|
||
searchInput.addEventListener('input', () => {
|
||
searchX.hidden = !searchInput.value;
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => { state.search = searchInput.value; apply(); }, 160);
|
||
});
|
||
searchInput.addEventListener('search', () => { // the field's own clear affordance
|
||
clearTimeout(searchTimer);
|
||
state.search = searchInput.value; apply();
|
||
});
|
||
searchX.addEventListener('click', () => { clearTimeout(searchTimer); clearOne('search'); searchInput.focus(); });
|
||
|
||
selSubject.addEventListener('change', () => { state.subject = selSubject.value; apply(); });
|
||
selAuthor.addEventListener('change', () => { state.author = selAuthor.value; apply(); });
|
||
|
||
function onYear(e) {
|
||
let lo = parseInt(loSlider.value, 10), hi = parseInt(hiSlider.value, 10);
|
||
if (lo > hi) {
|
||
// the handles crossed: push the *other* one along instead of swapping,
|
||
// so the thumb under the finger stays the one being dragged
|
||
if (e.target === loSlider) hi = lo; else lo = hi;
|
||
loSlider.value = lo; hiSlider.value = hi;
|
||
}
|
||
state.yearLo = lo; state.yearHi = hi;
|
||
state.yearTouched = !(lo === decadeMin && hi === decadeMax);
|
||
apply();
|
||
}
|
||
loSlider.addEventListener('input', onYear);
|
||
hiSlider.addEventListener('input', onYear);
|
||
|
||
clearBtn.addEventListener('click', () => {
|
||
clearTimeout(searchTimer);
|
||
state.search = ''; state.institution = ''; state.type = ''; state.author = ''; state.subject = '';
|
||
searchInput.value = ''; selInst.value = ''; selType.value = ''; selAuthor.value = ''; selSubject.value = '';
|
||
resetYear();
|
||
apply();
|
||
});
|
||
|
||
apply();
|
||
toggleFilters(!isMobile); // on a phone the wall matters more than the panel
|
||
}
|
||
|
||
// ─── load atlas + map, build scene ───────────────────────────────────────────
|
||
// content-length is the *compressed* size when a server gzips, while the
|
||
// reader hands back inflated bytes — so a raw ratio can sail past 100%
|
||
const pctOf = (loaded, total) => (total ? Math.min(100, Math.round((loaded / total) * 100)) : 0);
|
||
const asMB = bytes => `${(bytes / 1048576).toFixed(1)} MB`;
|
||
|
||
// read a body in chunks so the wait is legible instead of a frozen line
|
||
async function fetchWithProgress(url, onProgress) {
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
||
const total = parseInt(res.headers.get('content-length') || '0', 10);
|
||
const reader = res.body.getReader();
|
||
const chunks = [];
|
||
let loaded = 0;
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
chunks.push(value);
|
||
loaded += value.length;
|
||
onProgress(loaded, total);
|
||
}
|
||
return { blob: new Blob(chunks), total };
|
||
}
|
||
|
||
let lastActivity = performance.now();
|
||
let loadFinished = false;
|
||
const touchActivity = () => { lastActivity = performance.now(); };
|
||
|
||
// a bar that stops moving for half a minute is not "almost there": offer a way
|
||
// out without tearing down the load that may still be alive
|
||
const stallWatch = setInterval(() => {
|
||
if (loadFinished) { clearInterval(stallWatch); return; }
|
||
if (performance.now() - lastActivity < 30000 || document.getElementById('lento')) return;
|
||
const box = document.createElement('div');
|
||
box.id = 'lento';
|
||
box.textContent = 'o carregamento parou de avançar';
|
||
const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'tentar de novo';
|
||
btn.addEventListener('click', () => {
|
||
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
|
||
location.reload();
|
||
});
|
||
box.append(document.createElement('br'), btn);
|
||
(document.getElementById('title-screen')?.classList.contains('show')
|
||
? document.getElementById('title-screen') : document.getElementById('loading')).appendChild(box);
|
||
}, 3000);
|
||
|
||
async function main() {
|
||
// set before anything heavy: if this tab is killed mid-load, the automatic
|
||
// reload finds the mark and takes the light path
|
||
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
|
||
if (previousLoadDied) document.getElementById('aviso-leve').hidden = false;
|
||
|
||
// the catalogue is megabytes of its own, and until now nothing said so
|
||
const loadingMsg = document.getElementById('loading-msg');
|
||
const catalogue = await fetchWithProgress('atlas/atlas-map.json', (loaded, total) => {
|
||
touchActivity();
|
||
// a gzipped response often carries no content-length: count bytes instead
|
||
loadingMsg.textContent = total
|
||
? `carregando catálogo… ${pctOf(loaded, total)}%`
|
||
: `carregando catálogo… ${asMB(loaded)}`;
|
||
});
|
||
const mapData = JSON.parse(await catalogue.blob.text());
|
||
TPR = mapData.size / mapData.tile;
|
||
atlasEntries = mapData.map;
|
||
const handles = Object.keys(mapData.map);
|
||
|
||
// the note only earns its space in placeholder mode — the piece count already
|
||
// lives in the panel's own result line
|
||
const note = document.getElementById('filters-label');
|
||
note.hidden = !mapData.placeholder;
|
||
note.textContent = 'protótipo — as fotos ainda são um placeholder (uma única imagem de teste, tonalizada por item); os metadados são reais, do catalogo.jsonl';
|
||
|
||
buildGrid(handles, mapData.map, TPR, mapData.atlases.length);
|
||
|
||
// ── stats for the title screen: item count, year span, institution count ──
|
||
let minYear = Infinity, maxYear = -Infinity;
|
||
const institutions = new Set();
|
||
for (const entry of Object.values(mapData.map)) {
|
||
const meta = entry.meta || {};
|
||
if (meta.community) institutions.add(meta.community);
|
||
const m = String(meta.date || '').match(/(1[5-9]\d{2}|20\d{2})/);
|
||
if (m) {
|
||
const y = parseInt(m[1], 10);
|
||
if (y < minYear) minYear = y;
|
||
if (y > maxYear) maxYear = y;
|
||
}
|
||
}
|
||
document.getElementById('stat-pecas').textContent = handles.length.toLocaleString('pt-BR');
|
||
document.getElementById('stat-periodo').textContent =
|
||
minYear <= maxYear ? `${minYear}–${maxYear}` : 's.d.';
|
||
document.getElementById('stat-instituicoes').textContent = institutions.size || '—';
|
||
|
||
// ── show the title screen right away — the heavy atlas images load behind it ──
|
||
document.getElementById('loading-msg').remove();
|
||
const titleScreen = document.getElementById('title-screen');
|
||
titleScreen.classList.add('show');
|
||
const coverShownAt = performance.now();
|
||
const enterLabel = document.getElementById('enter-label');
|
||
const progressBar = document.getElementById('progress-bar');
|
||
|
||
// ── fetch each atlas with byte-level progress, build both the GPU texture
|
||
// and the plain <img> (for the card crop) from the same downloaded blob ──
|
||
// Decoding four 8192² pages takes about as long as fetching them does, so the
|
||
// bar covers both: parked at 100% for the whole decode, it read as stuck.
|
||
// phones get the light pages: same layout at 3072², so tiles keep their
|
||
// atlas/col/row and the uv math is unchanged
|
||
const pageSet = (lightMode && mapData.mobile) ? mapData.mobile : { size: mapData.size, atlases: mapData.atlases };
|
||
const nAtlases = pageSet.atlases.length;
|
||
const fileBytes = new Array(nAtlases).fill(0);
|
||
const fileTotals = new Array(nAtlases).fill(0);
|
||
let pagesReady = 0;
|
||
let shownPct = 0; // a progress bar that retreats is worse than a coarse one
|
||
let painting = false;
|
||
// the readers hand over thousands of chunks; repainting on each one is a few
|
||
// thousand pointless style invalidations, so coalesce to one write per frame
|
||
function reportProgress() {
|
||
if (painting) return;
|
||
painting = true;
|
||
requestAnimationFrame(() => { painting = false; paintProgress(); });
|
||
}
|
||
function paintProgress() {
|
||
const loaded = fileBytes.reduce((a, b) => a + b, 0);
|
||
const total = fileTotals.reduce((a, b) => a + b, 0);
|
||
const down = total ? Math.min(1, loaded / total) : 0;
|
||
// download 70%, decoding 20%, the first render's texture upload the last 10%
|
||
const pct = shownPct = Math.max(shownPct, Math.round((down * 0.7 + (pagesReady / nAtlases) * 0.2) * 100));
|
||
enterLabel.textContent = total
|
||
? `${down < 1 ? 'carregando' : 'preparando'} imagens… ${pct}%`
|
||
: `carregando imagens… ${asMB(loaded)}`;
|
||
progressBar.style.width = `${pct}%`;
|
||
touchActivity();
|
||
}
|
||
|
||
// every response's headers first, bodies second: reading as they arrived meant
|
||
// the last file's size landed mid-download and the percentage fell backwards
|
||
const responses = await Promise.all(pageSet.atlases.map(async name => {
|
||
const res = await fetch(`atlas/${name}`);
|
||
if (!res.ok) throw new Error(`atlas/${name}: HTTP ${res.status}`);
|
||
return res;
|
||
}));
|
||
responses.forEach((res, idx) => {
|
||
fileTotals[idx] = parseInt(res.headers.get('content-length') || '0', 10);
|
||
});
|
||
reportProgress();
|
||
|
||
const blobs = await Promise.all(responses.map(async (res, idx) => {
|
||
const reader = res.body.getReader();
|
||
const chunks = [];
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
chunks.push(value);
|
||
fileBytes[idx] += value.length;
|
||
reportProgress();
|
||
}
|
||
return new Blob(chunks, { type: 'image/webp' });
|
||
}));
|
||
|
||
// Each page is 8192² — ~268 MB of RGBA once it's a texture, and over
|
||
// maxTextureSize on plenty of phones (where the upload silently fails, or
|
||
// the tab is killed for holding four of them). Cap the page at what the
|
||
// device will actually take; at this zoom a tile is ~50 px on screen either
|
||
// way. Pages are decoded one at a time and released as we go, so the peak
|
||
// is one bitmap rather than the whole set.
|
||
const maxTex = Math.min(renderer.capabilities.maxTextureSize, isTouch ? 4096 : 8192);
|
||
|
||
const atlasMats = [];
|
||
atlasSources = [];
|
||
for (let b = 0; b < blobs.length; b++) {
|
||
const blob = blobs[b];
|
||
blobs[b] = null; // the compressed page is not needed once it is a bitmap
|
||
const url = URL.createObjectURL(blob);
|
||
const img = new Image();
|
||
img.src = url;
|
||
await img.decode().catch(() => {});
|
||
// a refused decode used to fall through as a blank page and a bar that
|
||
// never finished; say which page failed instead
|
||
if (!img.naturalWidth) throw new Error(`não foi possível decodificar ${pageSet.atlases[b]}`);
|
||
|
||
let source = img, size = img.naturalWidth;
|
||
if (size > maxTex) {
|
||
const c = document.createElement('canvas');
|
||
c.width = c.height = maxTex;
|
||
const ctx = c.getContext('2d');
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
ctx.drawImage(img, 0, 0, maxTex, maxTex);
|
||
source = c; size = maxTex;
|
||
img.src = ''; // drop the full-size bitmap before decoding the next page
|
||
}
|
||
URL.revokeObjectURL(url);
|
||
atlasSources.push({ source, size });
|
||
|
||
const tex = new THREE.Texture(source);
|
||
tex.colorSpace = THREE.SRGBColorSpace;
|
||
tex.generateMipmaps = false;
|
||
tex.minFilter = THREE.LinearFilter;
|
||
tex.needsUpdate = true;
|
||
atlasMats.push(tileMaterial(tex, TPR));
|
||
|
||
// upload this page now rather than letting all four land on the first
|
||
// render; the cost then falls inside the bar instead of after it
|
||
renderer.initTexture(tex);
|
||
pagesReady++;
|
||
reportProgress();
|
||
// let the bar actually repaint between pages
|
||
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
|
||
}
|
||
// dimming rides on a per-instance attribute, so one material per page is
|
||
// enough — and an opaque one, which spares the renderer a depth sort of the
|
||
// whole wall on every frame
|
||
pages.forEach((p, i) => { p.mesh.material = atlasMats[i]; });
|
||
|
||
setupFilters(mapData);
|
||
|
||
// The first render hands every atlas to the GPU — on a phone that is seconds
|
||
// of frozen main thread, and it used to happen with the bar already at 100%,
|
||
// which is what made the loader look permanently finished. Name the step, and
|
||
// let the browser actually paint that frame before the freeze begins.
|
||
enterLabel.textContent = 'montando o mural… 90%';
|
||
progressBar.style.width = '90%';
|
||
touchActivity();
|
||
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
|
||
|
||
// ── ready — start rendering the wall right away, behind a translucent
|
||
// title card, so the wall is already alive when the cover lifts ──
|
||
tick();
|
||
// two painted frames with every texture on the GPU: the heavy part is over,
|
||
// so the next visit — or a reload — takes the normal path again
|
||
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
|
||
loadFinished = true;
|
||
store('sessionStorage', s => s.removeItem(LOAD_MARK));
|
||
progressBar.style.width = '100%';
|
||
enterLabel.textContent = 'entrando…';
|
||
enterLabel.classList.add('ready');
|
||
document.getElementById('progress-track').style.display = 'none';
|
||
document.getElementById('loading').classList.add('ready');
|
||
|
||
// the cover is a title card, not a gate: it lifts on its own. A short floor
|
||
// keeps it from flashing past on a warm cache before anyone can read it.
|
||
const MIN_COVER_MS = 1100;
|
||
await new Promise(res => setTimeout(res, Math.max(0, MIN_COVER_MS - (performance.now() - coverShownAt))));
|
||
|
||
const cover = document.getElementById('loading');
|
||
cover.style.pointerEvents = 'none'; // the wall is usable from here on, not after the fade
|
||
titleScreen.classList.add('dissolve');
|
||
|
||
// the black waits for the words — a slight overlap so the two readings
|
||
// blend into one gesture instead of reading as two separate steps
|
||
await new Promise(res => setTimeout(res, 620));
|
||
cover.style.opacity = '0';
|
||
setTimeout(() => cover.remove(), 1000); // matches the .9s fade in the stylesheet
|
||
|
||
// ── history opens for business only now that every handle can be resolved ──
|
||
// The entry the wall starts from carries no piece and no hash, so the first
|
||
// back out of a card always lands on the bare wall and never off the site.
|
||
const deepLink = handleFromHash();
|
||
history.replaceState({ h: null }, '', location.pathname + location.search);
|
||
historyReady = true;
|
||
// a link straight to a piece: open it *as* a step, so back returns to the wall
|
||
const linked = deepLink && tileByHandle.get(deepLink);
|
||
if (linked) openTile(linked);
|
||
|
||
// on a phone the hint sits over the wall itself — it retires once read
|
||
if (isTouch) setTimeout(() => { document.getElementById('hint').style.opacity = '0'; }, 7000);
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error(err);
|
||
loadFinished = true;
|
||
showFailure('O mural não terminou de carregar.', `${err.message} — tentar de novo carrega a versão leve.`);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|