Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Religious Art Space</title> | |
| <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| background: #111; | |
| color: #ddd; | |
| font-family: system-ui, sans-serif; | |
| display: flex; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| #plot-wrap { flex: 1; min-width: 0; } | |
| /* Win against Plotly's own drag-layer cursor (it sets crosshair). */ | |
| #plot-wrap.clickable .nsewdrag, | |
| #plot-wrap.clickable .cursor-crosshair, | |
| #plot-wrap.clickable .cursor-pointer { cursor: pointer ; } | |
| #img-panel { | |
| width: 400px; | |
| background: #1c1c1c; | |
| border-right: 1px solid #2a2a2a; | |
| padding: 18px 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| overflow-y: auto; | |
| } | |
| #img-empty { | |
| color: #555; | |
| font-size: 13px; | |
| padding: 40px 0; | |
| text-align: center; | |
| } | |
| #img-content, #cluster-content, #summary-content { display: none; } | |
| body.has-image #summary-content { display: none; } | |
| body.has-image #img-content { display: flex; flex-direction: column; gap: 0; } | |
| body.has-cluster #summary-content { display: none; } | |
| body.has-cluster #cluster-content { display: flex; flex-direction: column; gap: 0; } | |
| /* Default state: nothing has-* β show the overview/subset summary panel. */ | |
| body:not(.has-image):not(.has-cluster) #summary-content { | |
| display: flex; flex-direction: column; gap: 0; | |
| } | |
| #img-empty { display: none; } /* replaced by #summary-content */ | |
| /* Persistent header β Clear button is visible in every panel state. */ | |
| #panel-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| margin-bottom: 10px; | |
| } | |
| #panel-header h2 { margin-bottom: 0; } | |
| #btn-clear { | |
| background: #222; | |
| border: 1px solid #333; | |
| color: #aaa; | |
| font-size: 11px; | |
| padding: 4px 10px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| white-space: nowrap; | |
| } | |
| #btn-clear:hover { background: #2a2a2a; color: #ddd; } | |
| /* ββ Summary panel ββ */ | |
| #summary-title { | |
| font-size: 15px; | |
| font-weight: 600; | |
| color: #eee; | |
| line-height: 1.4; | |
| margin-bottom: 6px; | |
| } | |
| #summary-subtitle { | |
| font-size: 12px; | |
| color: #888; | |
| margin-bottom: 12px; | |
| } | |
| /* ββ Cluster panel ββ */ | |
| #cluster-title { | |
| font-size: 15px; | |
| font-weight: 600; | |
| color: #eee; | |
| line-height: 1.4; | |
| margin-bottom: 6px; | |
| } | |
| #cluster-subtitle { | |
| font-size: 12px; | |
| color: #888; | |
| margin-bottom: 12px; | |
| } | |
| .religion-bars { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| margin-bottom: 4px; | |
| } | |
| .religion-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 11px; | |
| color: #aaa; | |
| } | |
| .religion-bar .rname { flex: 0 0 78px; text-transform: capitalize; } | |
| .religion-bar .rtrack { | |
| flex: 1; | |
| height: 6px; | |
| background: #222; | |
| border-radius: 3px; | |
| overflow: hidden; | |
| } | |
| .religion-bar .rfill { height: 100%; border-radius: 3px; } | |
| .religion-bar .rpct { flex: 0 0 36px; text-align: right; color: #888; font-variant-numeric: tabular-nums; } | |
| .sample-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 6px; | |
| margin-top: 4px; | |
| } | |
| .sample-grid img { | |
| width: 100%; | |
| aspect-ratio: 1; | |
| object-fit: cover; | |
| border-radius: 3px; | |
| background: #111; | |
| cursor: pointer; | |
| border: 1px solid #222; | |
| } | |
| .sample-grid img:hover { border-color: #555; } | |
| #sidebar { | |
| width: 260px; | |
| background: #1c1c1c; | |
| border-left: 1px solid #2a2a2a; | |
| padding: 18px 14px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| overflow-y: auto; | |
| } | |
| #sidebar-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| } | |
| #lang-toggle { display: flex; gap: 1px; border-radius: 4px; overflow: hidden; } | |
| .lang-btn { | |
| background: #222; | |
| border: 1px solid #333; | |
| color: #888; | |
| font-size: 10px; | |
| font-weight: 600; | |
| padding: 3px 7px; | |
| cursor: pointer; | |
| letter-spacing: .5px; | |
| } | |
| .lang-btn.active { background: #7c3aed22; border-color: #7c3aed; color: #a78bfa; } | |
| .lang-btn:hover:not(.active) { background: #2a2a2a; color: #ddd; } | |
| h1 { font-size: 13px; font-weight: 600; color: #fff; letter-spacing: .5px; } | |
| h2 { font-size: 11px; color: #777; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; } | |
| .slider-row { margin-bottom: 10px; } | |
| .slider-row label { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 12px; | |
| color: #bbb; | |
| margin-bottom: 4px; | |
| } | |
| .slider-row label span.val { color: #fff; font-variant-numeric: tabular-nums; } | |
| input[type=range] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 4px; | |
| border-radius: 2px; | |
| background: #333; | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 14px; height: 14px; | |
| border-radius: 50%; | |
| background: #888; | |
| cursor: pointer; | |
| } | |
| #img-title { | |
| font-size: 15px; | |
| font-weight: 600; | |
| color: #eee; | |
| line-height: 1.4; | |
| margin-bottom: 8px; | |
| } | |
| .religion-tag { | |
| display: inline-block; | |
| padding: 3px 12px; | |
| border-radius: 10px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| margin-bottom: 12px; | |
| color: #fff; | |
| } | |
| #img-panel img#img-preview { width: 100%; border-radius: 4px; display: block; } | |
| .meta-line { font-size: 13px; color: #666; line-height: 1.8; margin-top: 10px; } | |
| /* ββ Feature analysis ββ */ | |
| .section-divider { | |
| font-size: 11px; | |
| color: #444; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| border-top: 1px solid #2a2a2a; | |
| padding-top: 12px; | |
| margin-top: 16px; | |
| margin-bottom: 10px; | |
| } | |
| .feat-row { | |
| display: flex; | |
| gap: 10px; | |
| margin-bottom: 7px; | |
| align-items: baseline; | |
| } | |
| .feat-key { | |
| font-size: 11px; | |
| color: #555; | |
| flex: 0 0 76px; | |
| text-transform: uppercase; | |
| letter-spacing: .5px; | |
| } | |
| .feat-val { | |
| font-size: 13px; | |
| color: #bbb; | |
| flex: 1; | |
| line-height: 1.4; | |
| } | |
| .scalars-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| margin-top: 10px; | |
| } | |
| .scalar-chip { | |
| background: #222; | |
| border: 1px solid #2e2e2e; | |
| border-radius: 4px; | |
| padding: 3px 8px; | |
| font-size: 11px; | |
| color: #777; | |
| } | |
| .scalar-chip b { color: #aaa; font-weight: 500; } | |
| /* ββ Viz thumbnails ββ */ | |
| .viz-row { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| margin-top: 4px; | |
| } | |
| .viz-thumb-wrap { width: 100%; } | |
| .viz-thumb { | |
| width: 100%; | |
| border-radius: 4px; | |
| display: block; | |
| background: #111; | |
| } | |
| .viz-label { | |
| font-size: 11px; | |
| color: #555; | |
| margin-top: 5px; | |
| letter-spacing: .3px; | |
| } | |
| .dev-btn { | |
| width: 100%; | |
| padding: 7px; | |
| border-radius: 4px; | |
| border: none; | |
| font-size: 12px; | |
| cursor: pointer; | |
| margin-bottom: 6px; | |
| } | |
| #loading { | |
| display: none; | |
| position: fixed; | |
| bottom: 18px; left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0,0,0,.85); | |
| border: 1px solid #333; | |
| padding: 8px 20px; | |
| border-radius: 20px; | |
| font-size: 12px; | |
| z-index: 99; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="img-panel"> | |
| <div id="panel-header"> | |
| <h2 data-i18n="panel_overview">Overview</h2> | |
| <button id="btn-clear" data-i18n="clear_reset" data-i18n-title="clear_reset_title" | |
| title="Reset selection, zoom and panel">Clear / Reset</button> | |
| </div> | |
| <div id="summary-content"> | |
| <div id="summary-title" data-i18n="summary_all">All points</div> | |
| <div id="summary-subtitle"></div> | |
| <div class="section-divider" data-i18n="religion_mix">Religion mix</div> | |
| <div class="religion-bars" id="summary-religions"></div> | |
| <div class="section-divider" data-i18n="description">Description</div> | |
| <div id="summary-descriptions"></div> | |
| <div class="section-divider" data-i18n="samples">Samples</div> | |
| <div class="sample-grid" id="summary-samples"></div> | |
| </div> | |
| <div id="img-empty" data-i18n="click_hint">Click a point in the plot to see its image here.</div> | |
| <div id="img-content"> | |
| <!-- 1. Title --> | |
| <div id="img-title"></div> | |
| <!-- 2. Religion tag --> | |
| <div><span class="religion-tag" id="img-tag"></span></div> | |
| <!-- 3. Big image --> | |
| <img id="img-preview" src="" alt=""> | |
| <!-- 4. Artist Β· year Β· source --> | |
| <div class="meta-line" id="img-meta"></div> | |
| <!-- 5. Analysis --> | |
| <div class="section-divider" data-i18n="analysis">Analysis</div> | |
| <div id="feat-rows"></div> | |
| <div id="scalars-row" class="scalars-row"></div> | |
| <!-- 6. Visualizations (smaller) --> | |
| <div class="section-divider" data-i18n="visualizations">Visualizations</div> | |
| <div class="viz-row" id="viz-row"> | |
| <div class="viz-thumb-wrap" id="viz-canny-wrap"> | |
| <img class="viz-thumb" id="viz-canny" src="" alt="Canny"> | |
| <div class="viz-label" data-i18n="edges">Edges</div> | |
| </div> | |
| <div class="viz-thumb-wrap" id="viz-faces-wrap" style="display:none"> | |
| <img class="viz-thumb" id="viz-faces" src="" alt="Faces"> | |
| <div class="viz-label" data-i18n="faces">Faces</div> | |
| </div> | |
| <div class="viz-thumb-wrap" id="viz-pose-wrap" style="display:none"> | |
| <img class="viz-thumb" id="viz-pose" src="" alt="Pose"> | |
| <div class="viz-label" data-i18n="pose">Pose</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="cluster-content"> | |
| <div id="cluster-title"></div> | |
| <div id="cluster-subtitle"></div> | |
| <div class="section-divider" data-i18n="religion_mix">Religion mix</div> | |
| <div class="religion-bars" id="cluster-religions"></div> | |
| <div class="section-divider" data-i18n="description">Description</div> | |
| <div id="cluster-descriptions"></div> | |
| <div class="section-divider" data-i18n="samples">Samples</div> | |
| <div class="sample-grid" id="cluster-samples"></div> | |
| </div> | |
| </div> | |
| <div id="plot-wrap"></div> | |
| <div id="sidebar"> | |
| <div id="sidebar-header"> | |
| <h1 data-i18n="app_title">Religious Art Space</h1> | |
| <div id="lang-toggle"> | |
| <button class="lang-btn active" data-lang="en">EN</button> | |
| <button class="lang-btn" data-lang="sl">SL</button> | |
| </div> | |
| </div> | |
| <div> | |
| <h2 data-i18n="handcrafted">Hand-crafted</h2> | |
| <div class="slider-row"> | |
| <label><span data-i18n="color_histogram">Color histogram</span><span class="val" id="v-hc_color">1.0</span></label> | |
| <input type="range" id="sl-hc_color" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="color_flatness">Color flatness</span><span class="val" id="v-hc_flat">1.0</span></label> | |
| <input type="range" id="sl-hc_flat" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="geometric_patterns">Geometric patterns</span><span class="val" id="v-hc_geom">1.0</span></label> | |
| <input type="range" id="sl-hc_geom" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="lines">Lines</span><span class="val" id="v-hc_lines">1.0</span></label> | |
| <input type="range" id="sl-hc_lines" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="light_contrast">Light / contrast</span><span class="val" id="v-hc_light">1.0</span></label> | |
| <input type="range" id="sl-hc_light" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="symmetry">Symmetry</span><span class="val" id="v-hc_symmetry">1.0</span></label> | |
| <input type="range" id="sl-hc_symmetry" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| </div> | |
| <div> | |
| <h2 data-i18n="learned_other">Learned / other</h2> | |
| <div class="slider-row"> | |
| <label><span>DINOv2</span><span class="val" id="v-dino">1.0</span></label> | |
| <input type="range" id="sl-dino" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="clip_image">CLIP image</span><span class="val" id="v-clip_v">1.0</span></label> | |
| <input type="range" id="sl-clip_v" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="clip_attributes">CLIP attributes</span><span class="val" id="v-clip_s">1.0</span></label> | |
| <input type="range" id="sl-clip_s" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="pose">Pose</span><span class="val" id="v-pose">1.0</span></label> | |
| <input type="range" id="sl-pose" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| <div class="slider-row"> | |
| <label><span data-i18n="faces">Faces</span><span class="val" id="v-faces">1.0</span></label> | |
| <input type="range" id="sl-faces" min="0" max="2" step="0.1" value="1"> | |
| </div> | |
| </div> | |
| <div> | |
| <h2>CLIP Labels</h2> | |
| <div class="slider-row"> | |
| <label><span>Score threshold</span><span class="val" id="v-clip-thr">0.22</span></label> | |
| <input type="range" id="sl-clip-thr" min="0.10" max="0.35" step="0.01" value="0.22"> | |
| </div> | |
| <button class="dev-btn" id="btn-edit-labels" onclick="openLabelEditor()" | |
| style="width:100%;background:#1a2a1a;border:1px solid #2e7d32;color:#81c784;margin-bottom:6px;"> | |
| + Edit Labels | |
| </button> | |
| </div> | |
| <div style="display:flex; gap:8px;"> | |
| <button class="dev-btn" id="btn-zero" onclick="toggleAllZero()" style="flex:1;background:#222;border:1px solid #333;color:#aaa;">Set all to 0</button> | |
| <button class="dev-btn" id="btn-cluster" onclick="toggleClusters()" style="flex:1;background:#222;border:1px solid #333;color:#aaa;">Clusters</button> | |
| </div> | |
| </div> | |
| <!-- CLIP Label Editor Modal --> | |
| <div id="label-modal" style="display:none;position:fixed;inset:0;z-index:200; | |
| background:rgba(0,0,0,.7);align-items:center;justify-content:center;"> | |
| <div style="background:#181818;border:1px solid #333;border-radius:12px; | |
| width:min(680px,94vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;"> | |
| <div style="padding:18px 20px 12px;border-bottom:1px solid #2a2a2a; | |
| display:flex;align-items:center;justify-content:space-between;"> | |
| <h2 style="font-size:1rem;font-weight:600;">Edit CLIP Labels</h2> | |
| <button onclick="closeLabelEditor()" | |
| style="background:none;border:none;color:#666;font-size:1.3rem;cursor:pointer;line-height:1;">Γ</button> | |
| </div> | |
| <div id="label-editor-body" style="overflow-y:auto;padding:16px 20px;flex:1;"></div> | |
| <div style="padding:12px 20px;border-top:1px solid #2a2a2a; | |
| display:flex;gap:8px;align-items:center;"> | |
| <button onclick="restoreDefaultLabels()" | |
| style="padding:6px 14px;border-radius:6px;border:1px solid #333; | |
| background:none;color:#666;cursor:pointer;font-size:.8rem;"> | |
| βΊ Restore defaults | |
| </button> | |
| <button onclick="clearAllLabels()" | |
| style="padding:6px 14px;border-radius:6px;border:1px solid #333; | |
| background:none;color:#666;cursor:pointer;font-size:.8rem;margin-right:auto;"> | |
| β Clear all | |
| </button> | |
| <button onclick="closeLabelEditor()" | |
| style="padding:7px 18px;border-radius:6px;border:1px solid #333; | |
| background:#222;color:#aaa;cursor:pointer;font-size:.85rem;">Cancel</button> | |
| <button id="btn-save-labels" onclick="saveLabels()" | |
| style="padding:7px 18px;border-radius:6px;border:1px solid #2e7d32; | |
| background:#1a2a1a;color:#81c784;cursor:pointer;font-size:.85rem;font-weight:600;"> | |
| Save & Recompute | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="loading">β³ Recomputing t-SNEβ¦</div> | |
| <script> | |
| const COLORS = { | |
| christianity: "#4682b4", | |
| islam: "#55a868", | |
| buddhism: "#c44e52", | |
| hinduism: "#dd8452", | |
| }; | |
| // Muted versions shown when cluster overlay is active | |
| const COLORS_MUTED = { | |
| christianity: "#4a5e72", | |
| islam: "#4a6354", | |
| buddhism: "#6e4a4b", | |
| hinduism: "#6e5a48", | |
| }; | |
| const SLIDER_IDS = [ | |
| "hc_color", "hc_flat", "hc_geom", "hc_lines", "hc_light", "hc_symmetry", | |
| "dino", "clip_v", "clip_s", "pose", "faces", | |
| ]; | |
| const RELIGION_ORDER = ["christianity", "islam", "buddhism", "hinduism", "general"]; | |
| // ββ i18n βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // | |
| // Static UI strings keyed by `data-i18n` attributes plus dynamic strings | |
| // looked up via t(). Backend-generated description phrases (e.g. "warm | |
| // reds/oranges Β· vibrant") are translated by translateContent() β it splits | |
| // on " Β· " and pattern-matches each part against BACKEND_SL. | |
| const I18N = { | |
| en: { | |
| app_title: "Religious Art Space", | |
| panel_overview: "Overview", | |
| clear_reset: "Clear / Reset", | |
| clear_reset_title: "Reset selection, zoom and panel", | |
| summary_all: "All points", | |
| religion_mix: "Religion mix", | |
| description: "Description", | |
| samples: "Samples", | |
| analysis: "Analysis", | |
| visualizations: "Visualizations", | |
| edges: "Edges", | |
| faces: "Faces", | |
| pose: "Pose", | |
| click_hint: "Click a point in the plot to see its image here.", | |
| handcrafted: "Hand-crafted", | |
| learned_other: "Learned / other", | |
| color_histogram: "Color histogram", | |
| color_flatness: "Color flatness", | |
| geometric_patterns: "Geometric patterns", | |
| lines: "Lines", | |
| light_contrast: "Light / contrast", | |
| symmetry: "Symmetry", | |
| clip_image: "CLIP image", | |
| clip_attributes: "CLIP attributes", | |
| set_all_zero: "Set all to 0", | |
| set_all_one: "Set all to 1", | |
| clusters_btn: "Clusters", | |
| clusters_btn_on: "Clusters β", | |
| reprojecting: "Reprojectingβ¦", | |
| // dynamic helpers | |
| images: "images", | |
| of: "of", | |
| selection: "Selection", | |
| zoomed_view: "Zoomed view", | |
| cluster: "Cluster", | |
| cluster_of: "of", | |
| // religion display | |
| rel_christianity: "christianity", | |
| rel_islam: "islam", | |
| rel_buddhism: "buddhism", | |
| rel_hinduism: "hinduism", | |
| }, | |
| sl: { | |
| app_title: "Prostor verske umetnosti", | |
| panel_overview: "Pregled", | |
| clear_reset: "PoΔisti / Ponastavi", | |
| clear_reset_title: "Ponastavi izbor, poveΔavo in panel", | |
| summary_all: "Vse toΔke", | |
| religion_mix: "Verska sestava", | |
| description: "Opis", | |
| samples: "Vzorci", | |
| analysis: "Analiza", | |
| visualizations: "Vizualizacije", | |
| edges: "Robovi", | |
| faces: "Obrazi", | |
| pose: "DrΕΎa", | |
| click_hint: "Klikni toΔko v grafu, da se prikaΕΎe slika.", | |
| handcrafted: "RoΔno izdelane", | |
| learned_other: "NauΔene / ostalo", | |
| color_histogram: "Barvni histogram", | |
| color_flatness: "Ploskost barv", | |
| geometric_patterns: "Geometrijski vzorci", | |
| lines: "Linije", | |
| light_contrast: "Svetloba / kontrast", | |
| symmetry: "Simetrija", | |
| clip_image: "CLIP slika", | |
| clip_attributes: "CLIP atributi", | |
| set_all_zero: "Vse na 0", | |
| set_all_one: "Vse na 1", | |
| clusters_btn: "Skupine", | |
| clusters_btn_on: "Skupine β", | |
| reprojecting: "PreraΔunavanjeβ¦", | |
| images: "slik", | |
| of: "od", | |
| selection: "Izbor", | |
| zoomed_view: "PoveΔan pogled", | |
| cluster: "Skupina", | |
| cluster_of: "od", | |
| rel_christianity: "krΕ‘Δanstvo", | |
| rel_islam: "islam", | |
| rel_buddhism: "budizem", | |
| rel_hinduism: "hinduizem", | |
| }, | |
| }; | |
| // Backend description phrases (full strings the Python labellers emit). | |
| const BACKEND_SL = { | |
| // group headings (shown in feat-key) | |
| "Color": "Barva", | |
| "Flatness": "Ploskost", | |
| "Geometry": "Geometrija", | |
| "Lines": "Linije", | |
| "Light": "Svetloba", | |
| "Symmetry": "Simetrija", | |
| "CLIP attr": "CLIP atr.", | |
| "Pose": "DrΕΎa", | |
| "Faces": "Obrazi", | |
| // color | |
| "warm reds/oranges": "tople rdeΔo-oranΕΎne", | |
| "yellows/greens": "rumeno-zelene", | |
| "cool greens/cyans": "hladne zeleno-modre", | |
| "blues/purples": "modre/vijoliΔne", | |
| "vibrant": "ΕΎivahno", | |
| "muted/desaturated": "umirjeno/nenasiΔeno", | |
| // flatness | |
| "flat color fills": "ploskovne barvne ploskve", | |
| "painterly gradients": "slikarski prelivi", | |
| // geometry | |
| "repeating geometric patterns": "ponavljajoΔi geometrijski vzorci", | |
| "complex texture": "kompleksna tekstura", | |
| "uniform texture": "enotna tekstura", | |
| // lines | |
| "strong straight lines": "izrazite ravne linije", | |
| "curved/organic lines": "ukrivljene/organske linije", | |
| "dominant horizontal lines": "prevladujoΔe vodoravne linije", | |
| "dominant vertical lines": "prevladujoΔe navpiΔne linije", | |
| "dominant diagonalβ lines": "prevladujoΔe diagonalneβ linije", | |
| "dominant diagonalβ lines": "prevladujoΔe diagonalneβ linije", | |
| // light | |
| "bright": "svetlo", | |
| "dark": "temno", | |
| "heavy shadows": "moΔne sence", | |
| "high contrast": "visok kontrast", | |
| "low contrast": "nizek kontrast", | |
| // symmetry | |
| "left-right symmetric": "levo-desno simetriΔno", | |
| "top-bottom symmetric": "zgoraj-spodaj simetriΔno", | |
| "asymmetric": "asimetriΔno", | |
| // pose | |
| "figurative (bodies present)": "figuralno (s telesi)", | |
| "non-figurative": "nefiguralno", | |
| // faces | |
| "crowd/group scenes": "mnoΕΎiΔne/skupinske scene", | |
| "portrait/close-up": "portret / od blizu", | |
| "no faces": "brez obrazov", | |
| // scalar chip labels (long names) | |
| "Brightness": "Svetlost", | |
| "Contrast": "Kontrast", | |
| "Darkness": "Temnost", | |
| "Edge density": "Gostota robov", | |
| "Avg hue": "Povp. odtenek", | |
| "Avg saturation": "Povp. nasiΔenost", | |
| "Straight ratio": "Razmerje ravnih", | |
| "Hough lines": "Hough linije", | |
| "Face coverage": "Pokritost obraza", | |
| "Pose detected": "DrΕΎa zaznana", | |
| // CLIP labels | |
| "halo": "avreola", "cross": "kriΕΎ", "virgin mary": "Devica Marija", | |
| "angels": "angeli", "nativity": "rojstvo", "biblical figures": "svetopisemske osebe", | |
| "calligraphy": "kaligrafija", "arabesque": "arabeska", "mosque": "moΕ‘eja", | |
| "quran ms": "korana", "islamic tiles": "islamske ploΕ‘Δice", | |
| "buddha": "Buda", "lotus": "lotos", "thangka": "thangka", | |
| "mandala": "mandala", "dharma wheel": "kolo dharme", "bodhisattva": "bodisatva", | |
| "many-armed deity": "veΔroko boΕΎanstvo", "ganesha": "GaneΕ‘", | |
| "hindu temple": "hindujski tempelj", "krishna": "KriΕ‘na", | |
| "shiva nataraja": "Ε iva NataradΕΎa", "gold leaf": "zlat list", | |
| "prayer": "molitev", "sacred geometry": "sveta geometrija", | |
| "ritual objects": "obredni predmeti", "manuscript": "rokopis", | |
| }; | |
| let LANG = localStorage.getItem("lang") || "en"; | |
| function t(key) { | |
| return (I18N[LANG] && I18N[LANG][key]) || I18N.en[key] || key; | |
| } | |
| function translatePhrase(s) { | |
| if (LANG === "en" || !s) return s; | |
| if (BACKEND_SL[s]) return BACKEND_SL[s]; | |
| // "predominantly <religion>" β "preteΕΎno <religion-sl>" | |
| const m = s.match(/^predominantly (.+)$/); | |
| if (m) { | |
| const rel = m[1].trim(); | |
| return "preteΕΎno " + (t("rel_" + rel) || rel); | |
| } | |
| return s; | |
| } | |
| function translateContent(s) { | |
| // Backend joins multi-clause descriptions with " Β· ". | |
| if (LANG === "en" || !s) return s; | |
| return s.split(" Β· ").map(translatePhrase).join(" Β· "); | |
| } | |
| function translateReligion(rel) { | |
| return t("rel_" + rel) || rel; | |
| } | |
| function applyI18n() { | |
| document.querySelectorAll("[data-i18n]").forEach(el => { | |
| el.textContent = t(el.dataset.i18n); | |
| }); | |
| document.querySelectorAll("[data-i18n-title]").forEach(el => { | |
| el.title = t(el.dataset.i18nTitle); | |
| }); | |
| // Buttons whose label depends on toggle state | |
| document.getElementById("btn-zero").textContent = | |
| allZeroed ? t("set_all_one") : t("set_all_zero"); | |
| document.getElementById("btn-cluster").textContent = | |
| showClusters ? t("clusters_btn_on") : t("clusters_btn"); | |
| // Lang-toggle button active state | |
| document.querySelectorAll(".lang-btn").forEach(b => { | |
| b.classList.toggle("active", b.dataset.lang === LANG); | |
| }); | |
| } | |
| function setLanguage(lang) { | |
| if (lang === LANG) return; | |
| LANG = lang; | |
| localStorage.setItem("lang", lang); | |
| applyI18n(); | |
| // Repaint anything generated from data, not from data-i18n attributes. | |
| renderPlot(); | |
| if (selectedClusterId != null && clusters[selectedClusterId]) { | |
| showClusterInfo(selectedClusterId); | |
| } else if (document.body.classList.contains("has-image")) { | |
| // Best-effort: re-render the current image panel by matching the title. | |
| const cur = document.getElementById("img-title").textContent; | |
| const p = points.find(pt => (pt.title || pt.filename) === cur); | |
| if (p) showInfo(p); | |
| } else { | |
| showSummary(selectedIndices, selectedIndices ? null : t("summary_all")); | |
| } | |
| } | |
| document.querySelectorAll(".lang-btn").forEach(b => { | |
| b.addEventListener("click", () => setLanguage(b.dataset.lang)); | |
| }); | |
| let points = []; | |
| let showClusters = false; | |
| let clusters = []; | |
| let pointLabels = []; // per-point cluster id, -1 = noise | |
| let selectedClusterId = null; | |
| let selectedIndices = null; // lasso/box selection (null = none) | |
| // ββ Plot ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function buildTraces(pts) { | |
| const palette = showClusters ? COLORS_MUTED : COLORS; | |
| const opacity = showClusters ? 0.38 : 0.75; | |
| const religions = [...new Set(pts.map(p => p.religion))].sort(); | |
| return religions.map(rel => { | |
| const sub = pts.map((p, i) => ({ p, i })).filter(({ p }) => p.religion === rel); | |
| // In cluster mode each point's click routes to its cluster; noise (-1) | |
| // points get customdata = null, so the click handler ignores them. | |
| const customdata = showClusters | |
| ? sub.map(({ i }) => { | |
| const cid = pointLabels[i]; | |
| return cid >= 0 ? ["cluster", cid] : null; | |
| }) | |
| : sub.map(({ i }) => i); | |
| const text = showClusters | |
| ? sub.map(({ i }) => { | |
| const cid = pointLabels[i]; | |
| if (cid < 0 || !clusters[cid]) return ""; | |
| return `${clusters[cid].label}<br>${clusters[cid].size} images`; | |
| }) | |
| : undefined; | |
| return { | |
| x: sub.map(({ p }) => p.x), | |
| y: sub.map(({ p }) => p.y), | |
| customdata, | |
| text, | |
| hoverinfo: showClusters ? "text" : undefined, | |
| mode: "markers", | |
| type: "scattergl", | |
| name: translateReligion(rel), | |
| marker: { color: palette[rel] || "#666", size: 5, opacity }, | |
| }; | |
| }); | |
| } | |
| const HULL_PALETTE = [ | |
| [255,200,100],[100,180,255],[200,100,255],[80,220,160], | |
| [255,110,140],[140,230,80],[255,155,60], [60,160,255], | |
| [210,60,200], [60,210,160],[255,60,110], [200,210,60], | |
| ]; | |
| function buildHullTraces(clusts) { | |
| const traces = []; | |
| clusts.forEach((c, i) => { | |
| const [r,g,b] = HULL_PALETTE[i % HULL_PALETTE.length]; | |
| const selected = (i === selectedClusterId); | |
| // 1) filled outline β visual only (Plotly doesn't fire clicks on the fill) | |
| traces.push({ | |
| x: c.hull.map(p => p[0]), | |
| y: c.hull.map(p => p[1]), | |
| fill: "toself", | |
| fillcolor: `rgba(${r},${g},${b},${selected ? 0.38 : 0.22})`, | |
| line: { color: `rgba(${r},${g},${b},${selected ? 1.0 : 0.82})`, | |
| width: selected ? 2.5 : 1.5 }, | |
| mode: "lines", | |
| type: "scatter", | |
| showlegend: false, | |
| hoverinfo: "skip", | |
| }); | |
| // 2) invisible large centroid marker β the actual click target. | |
| // Carries customdata so the click handler can route to the right cluster. | |
| traces.push({ | |
| x: [c.cx], | |
| y: [c.cy], | |
| customdata: [["cluster", i]], | |
| mode: "markers", | |
| type: "scatter", | |
| marker: { size: 40, color: `rgba(${r},${g},${b},0.001)` }, | |
| showlegend: false, | |
| hoverinfo: "text", | |
| text: `${c.label}<br>${c.size} images`, | |
| hoverlabel: { bgcolor: "#000", bordercolor: "#555", font: { color: "#eee" } }, | |
| }); | |
| }); | |
| return traces; | |
| } | |
| function buildAnnotations() { | |
| return clusters.map(c => ({ | |
| x: c.cx, y: c.cy, text: c.label, | |
| showarrow: false, | |
| font: { color: "#fff", size: 11 }, | |
| bgcolor: "rgba(0,0,0,0.75)", | |
| bordercolor: "#555", | |
| borderwidth: 1, | |
| borderpad: 4, | |
| })); | |
| } | |
| function renderPlot() { | |
| const traces = [ | |
| ...(showClusters ? buildHullTraces(clusters) : []), | |
| ...buildTraces(points), | |
| ]; | |
| const layout = { | |
| paper_bgcolor: "#111", | |
| plot_bgcolor: "#111", | |
| font: { color: "#ccc" }, | |
| xaxis: { visible: false, zeroline: false }, | |
| yaxis: { visible: false, zeroline: false }, | |
| margin: { t: 10, b: 10, l: 10, r: 10 }, | |
| legend: { | |
| x: 0, y: 1, xanchor: "left", yanchor: "top", | |
| bgcolor: "rgba(0,0,0,0)", font: { size: 12 }, | |
| }, | |
| hovermode: "closest", | |
| dragmode: "zoom", | |
| annotations: showClusters ? buildAnnotations() : [], | |
| }; | |
| Plotly.react("plot-wrap", traces, layout, { responsive: true }); | |
| const el = document.getElementById("plot-wrap"); | |
| el.on("plotly_click", e => { | |
| const cd = e.points[0].customdata; | |
| if (Array.isArray(cd) && cd[0] === "cluster") { | |
| showClusterInfo(cd[1]); | |
| } else if (!showClusters && typeof cd === "number") { | |
| // Image-point clicks only work outside cluster mode. | |
| showInfo(points[cd]); | |
| } | |
| }); | |
| // Lasso / box-select β summarize selection, then drop the tool. We defer | |
| // the dragmode reset to the next tick so it lands AFTER Plotly finishes | |
| // its own internal post-selection housekeeping (otherwise it gets ignored). | |
| el.on("plotly_selected", e => { | |
| const dropTool = () => setTimeout(() => { | |
| Plotly.relayout("plot-wrap", { dragmode: "zoom" }); | |
| }, 0); | |
| if (!e || !e.points || e.points.length === 0) { | |
| showSummaryForAll(); | |
| dropTool(); | |
| return; | |
| } | |
| const idx = []; | |
| e.points.forEach(p => { | |
| const cd = p.customdata; | |
| if (typeof cd === "number") idx.push(cd); | |
| // cluster centroid markers (customdata = ["cluster", i]) are ignored. | |
| }); | |
| if (idx.length === 0) { showSummaryForAll(); dropTool(); return; } | |
| selectedIndices = idx; | |
| showSummary(idx, `${t("selection")} (${idx.length})`); | |
| dropTool(); | |
| }); | |
| // Zoom β summarize what's in view (debounced). | |
| el.on("plotly_relayout", e => { | |
| if (!e) return; | |
| // Ignore pure dragmode changes (no axis info), and selection-frame resets. | |
| const touchesAxes = | |
| "xaxis.range[0]" in e || "yaxis.range[0]" in e || | |
| "xaxis.autorange" in e || "yaxis.autorange" in e; | |
| if (!touchesAxes) return; | |
| scheduleZoomSummary(); | |
| }); | |
| // Cursor feedback: pointer when hovering anything clickable. Plotly stamps | |
| // its own crosshair on .nsewdrag, so we toggle a class and override via CSS. | |
| el.on("plotly_hover", e => { | |
| const cd = e.points[0].customdata; | |
| const clickable = | |
| (Array.isArray(cd) && cd[0] === "cluster") || | |
| (!showClusters && typeof cd === "number"); | |
| el.classList.toggle("clickable", clickable); | |
| }); | |
| el.on("plotly_unhover", () => { el.classList.remove("clickable"); }); | |
| } | |
| // ββ Info panel ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function showInfo(p) { | |
| document.body.classList.remove("has-cluster"); | |
| document.body.classList.add("has-image"); | |
| selectedClusterId = null; | |
| // 1. Title | |
| document.getElementById("img-title").textContent = p.title || p.filename; | |
| // 2. Religion tag | |
| const tag = document.getElementById("img-tag"); | |
| tag.textContent = translateReligion(p.religion); | |
| tag.style.background = COLORS[p.religion] || "#555"; | |
| // 3. Image | |
| document.getElementById("img-preview").src = `/images/${p.filename}`; | |
| // 4. Artist Β· year Β· source (no title here) | |
| document.getElementById("img-meta").innerHTML = [ | |
| p.artist || "", | |
| p.year || "", | |
| p.source ? `<span style="color:#444">${p.source}</span>` : "", | |
| ].filter(Boolean).join(" Β· "); | |
| // 6a. Canny (always) | |
| document.getElementById("viz-canny").src = `/api/viz/canny/${p.filename}`; | |
| // 6b. Faces (show only if model detects faces) | |
| const facesWrap = document.getElementById("viz-faces-wrap"); | |
| const facesImg = document.getElementById("viz-faces"); | |
| facesWrap.style.display = "none"; | |
| facesImg.onload = () => { facesWrap.style.display = "block"; }; | |
| facesImg.onerror = () => { facesWrap.style.display = "none"; }; | |
| facesImg.src = `/api/viz/faces/${p.filename}`; | |
| // 6c. Pose (show only if skeleton detected) | |
| const poseWrap = document.getElementById("viz-pose-wrap"); | |
| const poseImg = document.getElementById("viz-pose"); | |
| poseWrap.style.display = "none"; | |
| poseImg.onload = () => { poseWrap.style.display = "block"; }; | |
| poseImg.onerror = () => { poseWrap.style.display = "none"; }; | |
| poseImg.src = `/api/viz/pose/${p.filename}`; | |
| // 5. Feature descriptions | |
| const infoRes = await fetch(`/api/image_info/${p.filename}`); | |
| const info = await infoRes.json(); | |
| const featRows = document.getElementById("feat-rows"); | |
| featRows.innerHTML = ""; | |
| (info.descriptions || []).forEach(d => { | |
| const row = document.createElement("div"); | |
| row.className = "feat-row"; | |
| const key = translatePhrase(d.group); | |
| const val = translateContent(d.label); | |
| row.innerHTML = `<span class="feat-key">${key}</span><span class="feat-val">${val}</span>`; | |
| featRows.appendChild(row); | |
| }); | |
| const scalarsRow = document.getElementById("scalars-row"); | |
| scalarsRow.innerHTML = ""; | |
| const scalarLabels = { | |
| "Brightness": "bright", | |
| "Contrast": "contrast", | |
| "Darkness": "dark", | |
| "Edge density": "edges", | |
| "Avg hue": "hue", | |
| "Avg saturation":"sat", | |
| "Straight ratio":"straight", | |
| "Hough lines": "lines", | |
| "Faces": "faces", | |
| "Face coverage": "cov", | |
| "Pose detected": "pose", | |
| }; | |
| Object.entries(info.scalars || {}).forEach(([name, val]) => { | |
| const chip = document.createElement("span"); | |
| chip.className = "scalar-chip"; | |
| const short = scalarLabels[name] || name; | |
| const display = Number.isInteger(val) ? val : val.toFixed(2); | |
| chip.innerHTML = `<b>${short}</b> ${display}`; | |
| scalarsRow.appendChild(chip); | |
| }); | |
| } | |
| function showClusterInfo(id) { | |
| const c = clusters[id]; | |
| if (!c) return; | |
| selectedClusterId = id; | |
| document.body.classList.remove("has-image"); | |
| document.body.classList.add("has-cluster"); | |
| document.getElementById("cluster-title").textContent = | |
| c.label && c.label !== "cluster" | |
| ? translateContent(c.label) | |
| : `${t("cluster")} ${id + 1}`; | |
| document.getElementById("cluster-subtitle").textContent = | |
| `${c.size} ${t("images")} Β· ${t("cluster").toLowerCase()} ${id + 1} ${t("cluster_of")} ${clusters.length}`; | |
| // Religion mix bars | |
| const relWrap = document.getElementById("cluster-religions"); | |
| relWrap.innerHTML = ""; | |
| const total = Object.values(c.religion_counts || {}).reduce((a, b) => a + b, 0); | |
| Object.entries(c.religion_counts || {}) | |
| .sort((a, b) => b[1] - a[1]) | |
| .forEach(([rel, cnt]) => { | |
| const pct = total ? (cnt / total) * 100 : 0; | |
| const color = COLORS[rel] || "#666"; | |
| const row = document.createElement("div"); | |
| row.className = "religion-bar"; | |
| row.innerHTML = ` | |
| <span class="rname">${translateReligion(rel)}</span> | |
| <span class="rtrack"><span class="rfill" style="width:${pct.toFixed(1)}%;background:${color}"></span></span> | |
| <span class="rpct">${pct.toFixed(0)}%</span>`; | |
| relWrap.appendChild(row); | |
| }); | |
| // Per-group descriptions | |
| const descWrap = document.getElementById("cluster-descriptions"); | |
| descWrap.innerHTML = ""; | |
| (c.descriptions || []).forEach(d => { | |
| const row = document.createElement("div"); | |
| row.className = "feat-row"; | |
| const key = translatePhrase(d.group); | |
| const val = translateContent(d.label); | |
| row.innerHTML = `<span class="feat-key">${key}</span><span class="feat-val">${val}</span>`; | |
| descWrap.appendChild(row); | |
| }); | |
| // Sample thumbnails β click loads that image's full panel | |
| const sampWrap = document.getElementById("cluster-samples"); | |
| sampWrap.innerHTML = ""; | |
| (c.samples || []).forEach(fn => { | |
| const img = document.createElement("img"); | |
| img.src = `/images/${fn}`; | |
| img.title = fn; | |
| img.onclick = () => { | |
| const p = points.find(pt => pt.filename === fn); | |
| if (p) showInfo(p); | |
| }; | |
| sampWrap.appendChild(img); | |
| }); | |
| // Re-render so the selected hull gets its highlight styling. | |
| renderPlot(); | |
| } | |
| // ββ Summary panel (overview / lasso / zoom) ββββββββββββββββββββββββββββββββββ | |
| async function showSummary(indices, title) { | |
| // Switch to summary state β image/cluster panels hide. | |
| document.body.classList.remove("has-image"); | |
| document.body.classList.remove("has-cluster"); | |
| selectedClusterId = null; | |
| const res = await fetch("/api/subset_info", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ indices: indices || null }), | |
| }); | |
| const data = await res.json(); | |
| document.getElementById("summary-title").textContent = title || t("summary_all"); | |
| const sub = data.total && data.count !== data.total | |
| ? `${data.count} ${t("of")} ${data.total} ${t("images")}` | |
| : `${data.count} ${t("images")}`; | |
| document.getElementById("summary-subtitle").textContent = sub; | |
| // Religion mix bars | |
| const relWrap = document.getElementById("summary-religions"); | |
| relWrap.innerHTML = ""; | |
| const total = Object.values(data.religion_counts || {}).reduce((a, b) => a + b, 0); | |
| Object.entries(data.religion_counts || {}) | |
| .sort((a, b) => b[1] - a[1]) | |
| .forEach(([rel, cnt]) => { | |
| const pct = total ? (cnt / total) * 100 : 0; | |
| const color = COLORS[rel] || "#666"; | |
| const row = document.createElement("div"); | |
| row.className = "religion-bar"; | |
| row.innerHTML = ` | |
| <span class="rname">${translateReligion(rel)}</span> | |
| <span class="rtrack"><span class="rfill" style="width:${pct.toFixed(1)}%;background:${color}"></span></span> | |
| <span class="rpct">${pct.toFixed(0)}%</span>`; | |
| relWrap.appendChild(row); | |
| }); | |
| // Descriptions | |
| const descWrap = document.getElementById("summary-descriptions"); | |
| descWrap.innerHTML = ""; | |
| (data.descriptions || []).forEach(d => { | |
| const row = document.createElement("div"); | |
| row.className = "feat-row"; | |
| const key = translatePhrase(d.group); | |
| const val = translateContent(d.label); | |
| row.innerHTML = `<span class="feat-key">${key}</span><span class="feat-val">${val}</span>`; | |
| descWrap.appendChild(row); | |
| }); | |
| // Samples | |
| const sampWrap = document.getElementById("summary-samples"); | |
| sampWrap.innerHTML = ""; | |
| (data.samples || []).forEach(fn => { | |
| const img = document.createElement("img"); | |
| img.src = `/images/${fn}`; | |
| img.title = fn; | |
| img.onclick = () => { | |
| const p = points.find(pt => pt.filename === fn); | |
| if (p) showInfo(p); | |
| }; | |
| sampWrap.appendChild(img); | |
| }); | |
| } | |
| function showSummaryForAll() { | |
| selectedIndices = null; | |
| showSummary(null, "All points"); | |
| } | |
| // Debounced zoom-summary: filter currently-visible points and summarize them. | |
| let zoomTimer = null; | |
| function scheduleZoomSummary() { | |
| clearTimeout(zoomTimer); | |
| zoomTimer = setTimeout(computeZoomSummary, 250); | |
| } | |
| function computeZoomSummary() { | |
| const el = document.getElementById("plot-wrap"); | |
| const fl = el._fullLayout; | |
| if (!fl || !fl.xaxis || !fl.yaxis) return; | |
| const xr = fl.xaxis.range, yr = fl.yaxis.range; | |
| if (!xr || !yr) return; | |
| // Detect "fully zoomed out" β treat as overview. | |
| const xspan = xr[1] - xr[0], yspan = yr[1] - yr[0]; | |
| let allMinX = Infinity, allMaxX = -Infinity, allMinY = Infinity, allMaxY = -Infinity; | |
| for (const p of points) { | |
| if (p.x < allMinX) allMinX = p.x; | |
| if (p.x > allMaxX) allMaxX = p.x; | |
| if (p.y < allMinY) allMinY = p.y; | |
| if (p.y > allMaxY) allMaxY = p.y; | |
| } | |
| const isFullView = | |
| xr[0] <= allMinX - 0.001 && xr[1] >= allMaxX + 0.001 && | |
| yr[0] <= allMinY - 0.001 && yr[1] >= allMaxY + 0.001; | |
| if (isFullView) { showSummaryForAll(); return; } | |
| const idx = []; | |
| for (let i = 0; i < points.length; i++) { | |
| const p = points[i]; | |
| if (p.x >= xr[0] && p.x <= xr[1] && p.y >= yr[0] && p.y <= yr[1]) idx.push(i); | |
| } | |
| if (idx.length === 0) { showSummaryForAll(); return; } | |
| selectedIndices = idx; | |
| showSummary(idx, `${t("zoomed_view")} (${idx.length})`); | |
| } | |
| document.getElementById("btn-clear").onclick = () => { | |
| // Full reset β same state as a fresh page load. | |
| selectedIndices = null; | |
| selectedClusterId = null; | |
| // Drop any selection highlight on every trace. | |
| Plotly.restyle("plot-wrap", "selectedpoints", null); | |
| // Reset zoom, dragmode, and clear Plotly v2's persistent selection outline. | |
| Plotly.relayout("plot-wrap", { | |
| "xaxis.autorange": true, | |
| "yaxis.autorange": true, | |
| dragmode: "zoom", | |
| selections: [], | |
| }); | |
| showSummaryForAll(); | |
| }; | |
| // ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function init() { | |
| applyI18n(); | |
| const [embRes, thrRes] = await Promise.all([ | |
| fetch("/api/embeddings"), | |
| fetch("/api/clip_threshold"), | |
| ]); | |
| points = await embRes.json(); | |
| const { threshold } = await thrRes.json(); | |
| const sl = document.getElementById("sl-clip-thr"); | |
| sl.value = threshold; | |
| document.getElementById("v-clip-thr").textContent = threshold.toFixed(2); | |
| renderPlot(); | |
| showSummaryForAll(); | |
| } | |
| // ββ Sliders / Reproject βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function weights() { | |
| const w = {}; | |
| SLIDER_IDS.forEach(k => { w[k] = +document.getElementById("sl-" + k).value; }); | |
| return w; | |
| } | |
| let timer = null; | |
| function onSlider() { | |
| const w = weights(); | |
| SLIDER_IDS.forEach(k => { | |
| document.getElementById("v-" + k).textContent = w[k].toFixed(1); | |
| }); | |
| clearTimeout(timer); | |
| timer = setTimeout(() => reproject(w), 800); | |
| } | |
| async function reproject(w) { | |
| document.getElementById("loading").style.display = "flex"; | |
| try { | |
| const res = await fetch("/api/reproject", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(w), | |
| }); | |
| const coords = await res.json(); | |
| coords.forEach((c, i) => { points[i].x = c.x; points[i].y = c.y; }); | |
| clusters = []; pointLabels = []; selectedClusterId = null; | |
| document.body.classList.remove("has-cluster"); | |
| renderPlot(); | |
| } finally { | |
| document.getElementById("loading").style.display = "none"; | |
| } | |
| } | |
| let allZeroed = false; | |
| function toggleAllZero() { | |
| allZeroed = !allZeroed; | |
| const val = allZeroed ? 0 : 1; | |
| SLIDER_IDS.forEach(k => { | |
| document.getElementById("sl-" + k).value = val; | |
| document.getElementById("v-" + k).textContent = val.toFixed(1); | |
| }); | |
| const btn = document.getElementById("btn-zero"); | |
| btn.textContent = allZeroed ? t("set_all_one") : t("set_all_zero"); | |
| reproject(weights()); | |
| } | |
| SLIDER_IDS.forEach(k => | |
| document.getElementById("sl-" + k).addEventListener("input", onSlider) | |
| ); | |
| // ββ CLIP Score Threshold slider βββββββββββββββββββββββββββββββββββββββββββββββ | |
| let clipThrTimer = null; | |
| document.getElementById("sl-clip-thr").addEventListener("input", () => { | |
| const v = +document.getElementById("sl-clip-thr").value; | |
| document.getElementById("v-clip-thr").textContent = v.toFixed(2); | |
| clearTimeout(clipThrTimer); | |
| clipThrTimer = setTimeout(async () => { | |
| document.getElementById("loading").style.display = "flex"; | |
| try { | |
| const res = await fetch("/api/clip_threshold", { | |
| method: "POST", | |
| headers: {"Content-Type": "application/json"}, | |
| body: JSON.stringify({threshold: v}), | |
| }); | |
| const data = await res.json(); | |
| if (data.points) { | |
| data.points.forEach((c, i) => { if (points[i]) { points[i].x = c.x; points[i].y = c.y; } }); | |
| clusters = []; pointLabels = []; selectedClusterId = null; | |
| renderPlot(); | |
| Plotly.relayout("plot-wrap", {"xaxis.autorange": true, "yaxis.autorange": true}); | |
| } | |
| } finally { | |
| document.getElementById("loading").style.display = "none"; | |
| } | |
| }, 800); | |
| }); | |
| // ββ CLIP Label Editor βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const DEFAULT_CLIP_LABELS = { | |
| christianity: [ | |
| {label:"halo", prompt:"a golden halo around a figure"}, | |
| {label:"cross", prompt:"a crucifix or cross"}, | |
| {label:"virgin mary", prompt:"the virgin mary"}, | |
| {label:"angels", prompt:"angels with wings"}, | |
| {label:"nativity", prompt:"a nativity scene"}, | |
| {label:"biblical figs", prompt:"biblical figures in robes"}, | |
| ], | |
| islam: [ | |
| {label:"calligraphy", prompt:"arabic calligraphy"}, | |
| {label:"arabesque", prompt:"geometric arabesque patterns"}, | |
| {label:"mosque", prompt:"a mosque with a minaret"}, | |
| {label:"quran ms", prompt:"an illuminated quran manuscript"}, | |
| {label:"islamic tiles", prompt:"islamic tile patterns"}, | |
| ], | |
| buddhism: [ | |
| {label:"buddha", prompt:"a buddha statue"}, | |
| {label:"lotus", prompt:"a lotus flower"}, | |
| {label:"thangka", prompt:"a thangka painting"}, | |
| {label:"mandala", prompt:"a mandala"}, | |
| {label:"dharma wheel", prompt:"a dharma wheel"}, | |
| {label:"bodhisattva", prompt:"a bodhisattva figure"}, | |
| ], | |
| hinduism: [ | |
| {label:"many-armed", prompt:"a deity with multiple arms"}, | |
| {label:"ganesha", prompt:"ganesha the elephant god"}, | |
| {label:"hindu temple", prompt:"a colorful hindu temple"}, | |
| {label:"krishna", prompt:"krishna playing the flute"}, | |
| {label:"shiva nataraja",prompt:"dancing shiva nataraja"}, | |
| ], | |
| general: [ | |
| {label:"gold leaf", prompt:"gold leaf background"}, | |
| {label:"prayer", prompt:"religious figures in prayer"}, | |
| {label:"sacred geom.", prompt:"sacred geometry"}, | |
| {label:"ritual objects",prompt:"incense and ritual objects"}, | |
| {label:"manuscript", prompt:"a religious manuscript"}, | |
| ], | |
| }; | |
| let labelEditorState = {}; // religion β [{label, prompt}, ...] | |
| function restoreDefaultLabels() { | |
| labelEditorState = JSON.parse(JSON.stringify(DEFAULT_CLIP_LABELS)); | |
| renderLabelEditor(); | |
| } | |
| function clearAllLabels() { | |
| RELIGION_ORDER.forEach(rel => { labelEditorState[rel] = []; }); | |
| renderLabelEditor(); | |
| } | |
| async function openLabelEditor() { | |
| const res = await fetch("/api/clip_labels"); | |
| labelEditorState = await res.json(); | |
| renderLabelEditor(); | |
| document.getElementById("label-modal").style.display = "flex"; | |
| } | |
| function closeLabelEditor() { | |
| document.getElementById("label-modal").style.display = "none"; | |
| } | |
| const RELIGION_DISPLAY = { | |
| christianity: "Christianity", islam: "Islam", | |
| buddhism: "Buddhism", hinduism: "Hinduism", general: "General", | |
| }; | |
| function renderLabelEditor() { | |
| const body = document.getElementById("label-editor-body"); | |
| body.innerHTML = ""; | |
| RELIGION_ORDER.forEach(rel => { | |
| const entries = labelEditorState[rel] || []; | |
| const sec = document.createElement("div"); | |
| sec.style.cssText = "margin-bottom:18px;"; | |
| const heading = document.createElement("div"); | |
| heading.style.cssText = "font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#888;margin-bottom:8px;"; | |
| heading.textContent = RELIGION_DISPLAY[rel] || rel; | |
| sec.appendChild(heading); | |
| // Tag row | |
| const tagRow = document.createElement("div"); | |
| tagRow.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;"; | |
| entries.forEach((e, idx) => { | |
| const tag = document.createElement("span"); | |
| tag.title = e.prompt; | |
| tag.style.cssText = "display:inline-flex;align-items:center;gap:4px;padding:3px 8px 3px 10px;" + | |
| "background:#252525;border:1px solid #333;border-radius:20px;font-size:.78rem;color:#ccc;"; | |
| tag.innerHTML = `${e.label} <button onclick="removeLabel('${rel}',${idx})" style="background:none;border:none;color:#666;cursor:pointer;font-size:.9rem;line-height:1;padding:0 2px;" title="Remove">Γ</button>`; | |
| tagRow.appendChild(tag); | |
| }); | |
| sec.appendChild(tagRow); | |
| // Add row | |
| const addRow = document.createElement("div"); | |
| addRow.style.cssText = "display:flex;gap:6px;"; | |
| addRow.innerHTML = ` | |
| <input placeholder="Short label" id="add-label-${rel}" | |
| style="flex:1;min-width:80px;background:#111;border:1px solid #333;border-radius:6px; | |
| padding:5px 8px;color:#ddd;font-size:.8rem;" /> | |
| <input placeholder="Full prompt text" id="add-prompt-${rel}" | |
| style="flex:2;background:#111;border:1px solid #333;border-radius:6px; | |
| padding:5px 8px;color:#ddd;font-size:.8rem;" /> | |
| <button onclick="addLabel('${rel}')" | |
| style="padding:5px 12px;border-radius:6px;border:1px solid #2e7d32; | |
| background:#1a2a1a;color:#81c784;cursor:pointer;font-size:.85rem;font-weight:600;">+</button>`; | |
| sec.appendChild(addRow); | |
| // Enter key in either input triggers add | |
| ['add-label-', 'add-prompt-'].forEach(prefix => { | |
| setTimeout(() => { | |
| const el = document.getElementById(prefix + rel); | |
| if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') addLabel(rel); }); | |
| }, 0); | |
| }); | |
| body.appendChild(sec); | |
| }); | |
| } | |
| function removeLabel(rel, idx) { | |
| labelEditorState[rel].splice(idx, 1); | |
| renderLabelEditor(); | |
| } | |
| function addLabel(rel) { | |
| const lEl = document.getElementById("add-label-" + rel); | |
| const pEl = document.getElementById("add-prompt-" + rel); | |
| const label = lEl.value.trim(); | |
| const prompt = pEl.value.trim(); | |
| if (!label || !prompt) { lEl.focus(); return; } | |
| if (!labelEditorState[rel]) labelEditorState[rel] = []; | |
| labelEditorState[rel].push({ label, prompt }); | |
| lEl.value = ""; pEl.value = ""; | |
| renderLabelEditor(); | |
| } | |
| async function saveLabels() { | |
| const btn = document.getElementById("btn-save-labels"); | |
| btn.disabled = true; | |
| btn.textContent = "Savingβ¦"; | |
| try { | |
| const res = await fetch("/api/clip_labels", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(labelEditorState), | |
| }); | |
| const data = await res.json(); | |
| if (data.points) { | |
| data.points.forEach((c, i) => { if (points[i]) { points[i].x = c.x; points[i].y = c.y; } }); | |
| clusters = []; pointLabels = []; selectedClusterId = null; | |
| document.body.classList.remove("has-cluster"); | |
| } | |
| closeLabelEditor(); | |
| renderPlot(); | |
| Plotly.relayout("plot-wrap", { "xaxis.autorange": true, "yaxis.autorange": true }); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = "Save & Recompute"; | |
| } | |
| } | |
| // ββ Recompute t-SNE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function triggerRecompute() { | |
| const btn = document.getElementById("btn-recompute"); | |
| btn.disabled = true; | |
| btn.textContent = "βΊ Runningβ¦"; | |
| document.getElementById("loading").style.display = "flex"; | |
| try { | |
| const res = await fetch("/api/recompute", { method: "POST" }); | |
| const data = await res.json(); | |
| data.points.forEach((c, i) => { if (points[i]) { points[i].x = c.x; points[i].y = c.y; } }); | |
| clusters = []; pointLabels = []; selectedClusterId = null; | |
| document.body.classList.remove("has-cluster"); | |
| renderPlot(); | |
| Plotly.relayout("plot-wrap", { "xaxis.autorange": true, "yaxis.autorange": true }); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = "βΊ Recompute t-SNE"; | |
| document.getElementById("loading").style.display = "none"; | |
| } | |
| } | |
| // ββ Clusters ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function fetchClusters() { | |
| const res = await fetch("/api/cluster", { method: "POST" }); | |
| const data = await res.json(); | |
| clusters = data.clusters || []; | |
| pointLabels = data.point_labels || []; | |
| } | |
| async function toggleClusters() { | |
| showClusters = !showClusters; | |
| const btn = document.getElementById("btn-cluster"); | |
| if (showClusters) { | |
| btn.textContent = t("clusters_btn_on"); | |
| btn.style.background = "#7c3aed22"; | |
| btn.style.borderColor = "#7c3aed"; | |
| btn.style.color = "#a78bfa"; | |
| document.getElementById("loading").style.display = "flex"; | |
| try { | |
| await fetchClusters(); | |
| } finally { | |
| document.getElementById("loading").style.display = "none"; | |
| } | |
| } else { | |
| btn.textContent = t("clusters_btn"); | |
| btn.style.background = "#222"; | |
| btn.style.borderColor = "#333"; | |
| btn.style.color = "#aaa"; | |
| clusters = []; | |
| pointLabels = []; | |
| selectedClusterId = null; | |
| document.body.classList.remove("has-cluster"); | |
| } | |
| renderPlot(); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |