cvikl's picture
First commit.
fb91fdc
Raw
History Blame Contribute Delete
53.4 kB
<!DOCTYPE html>
<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 !important; }
#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 &amp; 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>