Detection Viewer

Rich viewer for object detection model outputs

    <canvas></canvas>

        Detections

        <button class="cp-btn toggle-image-btn active" title="Toggle base image (I)">Image</button>
        <button class="cp-btn reset-btn" title="Reset to defaults (R)">↺</button>
        <button class="cp-btn help-btn" title="Keyboard shortcuts (?)">?</button>
        <button class="cp-btn maximize-btn" title="Maximize (F)">⛶</button>

        Keyboard Shortcuts
        <button class="help-close-btn">×</button>

?	Show keyboard shortcuts

F	Toggle maximize

I	Toggle base image

A	Toggle all annotations

H	Hide selected annotation

Shift+Click	Hide clicked annotation

Esc	Deselect / exit maximize

+ / -	Zoom in / out

0	Reset zoom

R	Reset all to defaults

No data

.pose-viewer-container {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    user-select: none;
    width: 100%;
    position: relative;
    box-sizing: border-box;
}

/* ── Maximized Overlay ── */

.pose-viewer-container.maximized {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    z-index: 9999;
    background: var(--background-fill-primary, #fff);
    display: flex;
    flex-direction: column;
    padding: 0;
    overflow: hidden;
}

.pose-viewer-container.maximized .canvas-wrapper {
    flex: 1;
    min-height: 0;
    border-radius: 0;
    align-items: center;
}

.pose-viewer-container.maximized .control-panel {
    flex-shrink: 0;
    max-height: 40vh;
    border-radius: 0;
    border-left: none;
    border-right: none;
    border-bottom: none;
    margin-top: 0;
}

.pose-viewer-container.maximized .maximize-btn {
    background: var(--color-accent, #2196F3);
    color: #fff;
    border-color: var(--color-accent, #2196F3);
}

/* ── Canvas ── */

.pose-viewer-container .canvas-wrapper {
    display: none;
    justify-content: center;
    border-radius: 4px;
    overflow: hidden;
}

.pose-viewer-container .canvas-wrapper canvas {
    display: block;
    touch-action: none;
}

/* ── Loading Indicator ── */

.pose-viewer-container .loading-indicator {
    display: none;
    justify-content: center;
    align-items: center;
    border: 2px dashed var(--border-color-primary, #d0d0d0);
    border-radius: 8px;
    padding: 48px 24px;
}

.pose-viewer-container .loading-indicator.visible {
    display: flex;
}

.pose-viewer-container .loading-spinner {
    width: 24px;
    height: 24px;
    border: 3px solid var(--border-color-primary, #d0d0d0);
    border-top-color: var(--color-accent, #2196F3);
    border-radius: 50%;
    animation: detection-viewer-spin 0.8s linear infinite;
}

@keyframes detection-viewer-spin {
    to { transform: rotate(360deg); }
}

/* ── Placeholder ── */

.pose-viewer-container .placeholder {
    border: 2px dashed var(--border-color-primary, #d0d0d0);
    border-radius: 8px;
    padding: 48px 24px;
    text-align: center;
    color: var(--body-text-color-subdued, #888);
    font-size: 14px;
}

.pose-viewer-container .placeholder.hidden {
    display: none;
}

/* ── Tooltip ── */

.pose-viewer-container .tooltip {
    display: none;
    position: absolute;
    background: rgba(0, 0, 0, 0.8);
    color: #fff;
    padding: 4px 8px;
    border-radius: 4px;
    font-size: 12px;
    pointer-events: none;
    white-space: nowrap;
    z-index: 10;
}

.pose-viewer-container .tooltip.visible {
    display: block;
}

/* ── Control Panel ── */

.pose-viewer-container .control-panel {
    display: none;
    flex-direction: column;
    border: 1px solid var(--border-color-primary, #e0e0e0);
    border-radius: 6px;
    margin-top: 8px;
    font-size: 12px;
    overflow: hidden;
}

.pose-viewer-container .control-panel.visible {
    display: flex;
}

.pose-viewer-container .control-panel-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 10px;
    background: var(--background-fill-secondary, #f7f7f7);
    border-bottom: 1px solid var(--border-color-primary, #e0e0e0);
}

.pose-viewer-container .control-panel-header-info {
    display: flex;
    align-items: center;
    gap: 8px;
}

.pose-viewer-container .control-panel-title {
    font-weight: 600;
    color: var(--body-text-color, #333);
}

.pose-viewer-container .control-panel-count {
    font-weight: 400;
    color: var(--body-text-color-subdued, #888);
}

.pose-viewer-container .control-panel-actions {
    display: flex;
    align-items: center;
    gap: 4px;
}

.pose-viewer-container .cp-btn {
    all: unset;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    height: 22px;
    padding: 0 8px;
    background: transparent;
    border: 1px solid var(--border-color-primary, #d0d0d0);
    border-radius: 3px;
    cursor: pointer;
    font-size: 11px;
    line-height: 1;
    color: var(--body-text-color, #333);
    box-sizing: border-box;
}

.pose-viewer-container .cp-btn:hover {
    background: var(--background-fill-primary, #eee);
}

.pose-viewer-container .toggle-image-btn.active {
    background: var(--color-accent, #2196F3);
    color: #fff;
    border-color: var(--color-accent, #2196F3);
}

.pose-viewer-container .toggle-image-btn.active:hover {
    opacity: 0.9;
}

.pose-viewer-container .control-panel-body {
    overflow-y: auto;
    min-height: 0;
}

.pose-viewer-container .annotation-rows {
    max-height: 300px;
    overflow: hidden auto;
}

/* ── Select-All Row ── */

.pose-viewer-container .select-all-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 5px 10px;
    border-bottom: 1px solid var(--border-color-primary, #e0e0e0);
    border-left: 3px solid transparent;
    padding-left: 28px;
}

.pose-viewer-container .select-all-row .select-all-checkbox {
    flex-shrink: 0;
    cursor: pointer;
}

.pose-viewer-container .select-all-row .select-all-label {
    font-size: 12px;
    font-weight: 600;
    color: var(--body-text-color, #333);
}

/* ── Annotation Row ── */

.pose-viewer-container .annotation-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 5px 10px;
    cursor: pointer;
    border-left: 3px solid transparent;
    transition: background 0.15s;
}

.pose-viewer-container .annotation-row:hover {
    background: var(--background-fill-secondary, #f5f5f5);
}

.pose-viewer-container .annotation-row .ann-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    flex-shrink: 0;
}

.pose-viewer-container .annotation-row .ann-checkbox {
    flex-shrink: 0;
    cursor: pointer;
}

.pose-viewer-container .annotation-row .ann-label {
    flex: 1;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--body-text-color, #333);
}

.pose-viewer-container .annotation-row .ann-summary {
    color: var(--body-text-color-subdued, #888);
    white-space: nowrap;
    font-size: 11px;
}

/* ── Expand Button ── */

.pose-viewer-container .annotation-row .ann-expand {
    all: unset;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    cursor: pointer;
    font-size: 8px;
    color: var(--body-text-color-subdued, #888);
    border-radius: 3px;
    transition: transform 0.15s;
}

.pose-viewer-container .annotation-row .ann-expand:hover {
    background: var(--border-color-primary, #e0e0e0);
    color: var(--body-text-color, #333);
}

.pose-viewer-container .annotation-row .ann-expand.expanded {
    transform: rotate(90deg);
}

/* ── Selected Row ── */

.pose-viewer-container .annotation-row.selected {
    border-left-color: var(--color-accent, #2196F3);
    background: var(--background-fill-secondary, #f0f7ff);
}

.pose-viewer-container .annotation-row.below-threshold {
    opacity: 0.4;
}

/* ── Annotation Detail ── */

.pose-viewer-container .annotation-detail {
    display: none;
    padding: 6px 10px 8px 31px;
    font-size: 11px;
    color: var(--body-text-color-subdued, #666);
    border-bottom: 1px solid var(--border-color-primary, #eee);
    max-height: 150px;
    overflow-y: auto;
}

.pose-viewer-container .annotation-detail.visible {
    display: block;
}

.pose-viewer-container .annotation-detail table {
    width: 100%;
    border-collapse: collapse;
}

.pose-viewer-container .annotation-detail td {
    padding: 1px 6px 1px 0;
}

.pose-viewer-container .annotation-detail .detail-section-title {
    font-weight: 600;
    margin-bottom: 2px;
    color: var(--body-text-color, #333);
}

/* ── Layer Toggles ── */

.pose-viewer-container .layer-toggles {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 6px 10px;
    border-bottom: 1px solid var(--border-color-primary, #e0e0e0);
}

.pose-viewer-container .layer-btn {
    all: unset;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    height: 22px;
    padding: 0 8px;
    background: var(--background-fill-primary, #eee);
    border: 1px solid var(--border-color-primary, #d0d0d0);
    border-radius: 3px;
    cursor: pointer;
    font-size: 11px;
    line-height: 1;
    color: var(--body-text-color, #333);
    box-sizing: border-box;
}

.pose-viewer-container .layer-btn.active {
    background: var(--color-accent, #2196F3);
    color: #fff;
    border-color: var(--color-accent, #2196F3);
}

/* ── Label Filters ── */

.pose-viewer-container .label-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px;
    padding: 6px 10px;
    border-bottom: 1px solid var(--border-color-primary, #e0e0e0);
}

.pose-viewer-container .label-filters-title {
    font-size: 11px;
    font-weight: 600;
    color: var(--body-text-color, #333);
    margin-right: 4px;
}

.pose-viewer-container .label-filter-btn {
    all: unset;
    display: inline-flex;
    align-items: center;
    touch-action: manipulation;
    gap: 4px;
    height: 22px;
    padding: 0 8px;
    background: var(--background-fill-primary, #eee);
    border: 1px solid var(--border-color-primary, #d0d0d0);
    border-radius: 3px;
    cursor: pointer;
    font-size: 11px;
    line-height: 1;
    color: var(--body-text-color-subdued, #888);
    box-sizing: border-box;
}

.pose-viewer-container .label-filter-btn.active {
    color: var(--body-text-color, #333);
    border-color: var(--body-text-color-subdued, #888);
}

.pose-viewer-container .label-color-dot {
    display: inline-block;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex-shrink: 0;
}

.pose-viewer-container .label-count {
    opacity: 0.6;
}

/* ── Sort Controls ── */

.pose-viewer-container .sort-controls {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 6px 10px;
    border-bottom: 1px solid var(--border-color-primary, #e0e0e0);
}

.pose-viewer-container .sort-controls-title {
    font-size: 11px;
    font-weight: 600;
    color: var(--body-text-color, #333);
    margin-right: 4px;
}

.pose-viewer-container .sort-btn {
    all: unset;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    height: 22px;
    padding: 0 8px;
    background: var(--background-fill-primary, #eee);
    border: 1px solid var(--border-color-primary, #d0d0d0);
    border-radius: 3px;
    cursor: pointer;
    font-size: 11px;
    line-height: 1;
    color: var(--body-text-color-subdued, #888);
    box-sizing: border-box;
}

.pose-viewer-container .sort-btn.active {
    color: var(--body-text-color, #333);
    border-color: var(--body-text-color-subdued, #888);
}

/* ── Annotation Group Separator ── */

.pose-viewer-container .annotation-group-separator {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 4px 10px;
    font-size: 10px;
    color: var(--body-text-color-subdued, #888);
}

.pose-viewer-container .annotation-group-separator::before,
.pose-viewer-container .annotation-group-separator::after {
    content: "";
    flex: 1;
    height: 1px;
    background: var(--border-color-primary, #e0e0e0);
}

/* ── Filtered-out Row ── */

.pose-viewer-container .annotation-row.filtered-out {
    opacity: 0.4;
}

/* ── Threshold Slider ── */

.pose-viewer-container .threshold-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 5px 10px;
    border-bottom: 1px solid var(--border-color-primary, #e0e0e0);
    font-size: 11px;
    color: var(--body-text-color, #333);
}

.pose-viewer-container .threshold-row input[type="range"] {
    flex: 1;
    height: 14px;
    margin: 0;
    cursor: pointer;
    -webkit-appearance: none;
    appearance: none;
    background: transparent;
}

.pose-viewer-container .threshold-row input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 14px;
    height: 14px;
    border-radius: 50%;
    background: var(--color-accent, #2196F3);
    border: 2px solid #fff;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    cursor: pointer;
}

.pose-viewer-container .threshold-row input[type="range"]::-moz-range-thumb {
    width: 14px;
    height: 14px;
    border-radius: 50%;
    background: var(--color-accent, #2196F3);
    border: 2px solid #fff;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    cursor: pointer;
}

.pose-viewer-container .threshold-row .threshold-value,
.pose-viewer-container .threshold-row .slider-value {
    min-width: 32px;
    text-align: right;
    color: var(--body-text-color-subdued, #888);
}

/* ── Dual Range Slider ── */

.pose-viewer-container .dual-range-wrapper {
    position: relative;
    flex: 1;
    height: 4px;
    border-radius: 2px;
    background: var(--border-color-primary, #d0d0d0);
}

.pose-viewer-container .dual-range-wrapper input[type="range"] {
    position: absolute;
    top: 50%;
    left: 0;
    transform: translateY(-50%);
    width: 100%;
    height: 14px;
    margin: 0;
    padding: 0;
    background: transparent;
    pointer-events: none;
    -webkit-appearance: none;
    appearance: none;
}

.pose-viewer-container .dual-range-wrapper input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 14px;
    height: 14px;
    border-radius: 50%;
    background: var(--color-accent, #2196F3);
    border: 2px solid #fff;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    cursor: pointer;
    pointer-events: all;
}

.pose-viewer-container .dual-range-wrapper input[type="range"]::-moz-range-thumb {
    width: 14px;
    height: 14px;
    border-radius: 50%;
    background: var(--color-accent, #2196F3);
    border: 2px solid #fff;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    cursor: pointer;
    pointer-events: all;
}

.pose-viewer-container .dual-range-wrapper input[type="range"]::-webkit-slider-runnable-track {
    background: transparent;
}

.pose-viewer-container .dual-range-wrapper input[type="range"]::-moz-range-track {
    background: transparent;
}

/* ── Draw Options (collapsible) ── */

.pose-viewer-container .draw-options {
    border-bottom: 1px solid var(--border-color-primary, #e0e0e0);
}

.pose-viewer-container .draw-options-toggle {
    all: unset;
    display: flex;
    align-items: center;
    gap: 4px;
    width: 100%;
    padding: 4px 10px;
    cursor: pointer;
    font-size: 11px;
    color: var(--body-text-color-subdued, #888);
    box-sizing: border-box;
}

.pose-viewer-container .draw-options-toggle:hover {
    background: var(--background-fill-secondary, #f5f5f5);
}

.pose-viewer-container .draw-options-arrow {
    font-size: 8px;
    transition: transform 0.15s;
}

.pose-viewer-container .draw-options.open .draw-options-arrow {
    transform: rotate(90deg);
}

.pose-viewer-container .draw-options-body {
    display: none;
}

.pose-viewer-container .draw-options.open .draw-options-body {
    display: block;
}

.pose-viewer-container .draw-options-body .threshold-row {
    border-bottom: none;
    padding-top: 2px;
    padding-bottom: 2px;
}

.pose-viewer-container .draw-options-body .threshold-row:last-child {
    padding-bottom: 6px;
}

/* ── Help Overlay ── */

.pose-viewer-container .help-overlay {
    display: none;
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.4);
    z-index: 10000;
    justify-content: center;
    align-items: center;
}

.pose-viewer-container.maximized .help-overlay {
    position: fixed;
}

.pose-viewer-container .help-overlay.visible {
    display: flex;
}

.pose-viewer-container .help-dialog {
    background: var(--background-fill-primary, #fff);
    border: 1px solid var(--border-color-primary, #e0e0e0);
    border-radius: 8px;
    padding: 16px 20px;
    max-width: 320px;
    width: 90%;
    box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}

.pose-viewer-container .help-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 12px;
    font-size: 14px;
    font-weight: 600;
    color: var(--body-text-color, #333);
}

.pose-viewer-container .help-close-btn {
    all: unset;
    cursor: pointer;
    font-size: 18px;
    line-height: 1;
    color: var(--body-text-color-subdued, #888);
    padding: 0 4px;
}

.pose-viewer-container .help-close-btn:hover {
    color: var(--body-text-color, #333);
}

.pose-viewer-container .help-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 12px;
    color: var(--body-text-color, #333);
}

.pose-viewer-container .help-table td {
    padding: 4px 0;
}

.pose-viewer-container .help-table td:first-child {
    white-space: nowrap;
    padding-right: 16px;
}

.pose-viewer-container .help-table kbd {
    display: inline-block;
    background: var(--background-fill-secondary, #f3f3f3);
    border: 1px solid var(--border-color-primary, #d0d0d0);
    border-radius: 3px;
    padding: 1px 5px;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
    font-size: 11px;
    line-height: 1.4;
}

// Guard: skip if already initialized
if (element._poseInitialized) return;
element._poseInitialized = true;

(function () {
    "use strict";

    var MAX_CANVAS_HEIGHT = 600;
    var KEYPOINT_RADIUS = 3;
    var HIT_RADIUS = 10;
    var MASK_ALPHA = 0.4;
    var CONNECTION_ALPHA = 0.7;
    var CONNECTION_WIDTH = 2;
    var BBOX_LINE_WIDTH = 2;
    var BBOX_LABEL_FONT = "bold 11px -apple-system, BlinkMacSystemFont, sans-serif";
    var DIM_ALPHA = 0.2;
    var MIN_ZOOM = 1;
    var MAX_ZOOM = 20;
    var ZOOM_SENSITIVITY = 0.001;
    var DRAG_THRESHOLD = 4;

    // ── DOM References ─────────────────────────────────────────────

    var container = element.querySelector(".pose-viewer-container");
    var dataScript = element.querySelector("script.pose-data");
    var canvasWrapper = element.querySelector(".canvas-wrapper");
    var canvas = element.querySelector("canvas");
    var ctx = canvas.getContext("2d");
    var tooltip = element.querySelector(".tooltip");
    var controlPanel = element.querySelector(".control-panel");
    var annotationList = element.querySelector(".annotation-list");

    // Read configurable threshold defaults
    var initialScoreThresholdMin = parseFloat(container.getAttribute("data-score-threshold-min")) || 0;
    var initialScoreThresholdMax = parseFloat(container.getAttribute("data-score-threshold-max"));
    if (isNaN(initialScoreThresholdMax)) initialScoreThresholdMax = 1;
    var initialKeypointThreshold = parseFloat(container.getAttribute("data-keypoint-threshold")) || 0;
    var initialKeypointRadius = parseInt(container.getAttribute("data-keypoint-radius"), 10);
    if (isNaN(initialKeypointRadius) || initialKeypointRadius < 1) initialKeypointRadius = KEYPOINT_RADIUS;
    var toggleImageBtn = element.querySelector(".toggle-image-btn");
    var resetBtn = element.querySelector(".reset-btn");
    var maximizeBtn = element.querySelector(".maximize-btn");
    var helpBtn = element.querySelector(".help-btn");
    var helpOverlay = element.querySelector(".help-overlay");
    var helpCloseBtn = element.querySelector(".help-close-btn");
    var loadingIndicator = element.querySelector(".loading-indicator");
    var placeholder = element.querySelector(".placeholder");
    var countEl = element.querySelector(".control-panel-count");

    // ── State ──────────────────────────────────────────────────────

    var state = {
        image: null,
        annotations: [],
        scale: 1,
        visibility: [],
        selectedIndex: -1,
        showImage: true,
        layers: { masks: true, boxes: true, skeleton: true, keypoints: true },
        thresholdMin: initialScoreThresholdMin,
        thresholdMax: initialScoreThresholdMax,
        keypointThreshold: initialKeypointThreshold,
        keypointRadius: initialKeypointRadius,
        connectionWidth: CONNECTION_WIDTH,
        maskAlpha: MASK_ALPHA,
        connectionAlpha: CONNECTION_ALPHA,
        bboxLineWidth: BBOX_LINE_WIDTH,
        labelVisibility: {},
        maskImages: [],
        zoom: 1,
        panX: 0,
        panY: 0,
        isPanning: false,
        panStartX: 0,
        panStartY: 0,
        panStartPanX: 0,
        panStartPanY: 0,
        didDrag: false,
        maximized: false,
        sortMode: "none",
        sortedIndices: [],
        expandedIndex: -1,
        drawOptionsOpen: false
    };

    // ── MutationObserver for value changes ─────────────────────────

    var observer = new MutationObserver(function () {
        handleValueChange();
    });
    observer.observe(dataScript, { childList: true, characterData: true, subtree: true });

    // Also handle initial value
    handleValueChange();

    // ── Value Change Handler ───────────────────────────────────────

    function handleValueChange() {
        var raw = dataScript.textContent.trim();
        if (!raw || raw === "null") {
            showPlaceholder();
            return;
        }

        var data;
        try {
            data = JSON.parse(raw);
        } catch (e) {
            showPlaceholder();
            return;
        }

        if (!data || !data.image) {
            showPlaceholder();
            return;
        }

        // Update score threshold if provided in payload
        if (data.scoreThresholdMin != null) {
            initialScoreThresholdMin = data.scoreThresholdMin;
            state.thresholdMin = data.scoreThresholdMin;
        }
        if (data.scoreThresholdMax != null) {
            initialScoreThresholdMax = data.scoreThresholdMax;
            state.thresholdMax = data.scoreThresholdMax;
        }

        // Show loading spinner while the image is being fetched.
        // Gradio's updateDOM() resets JS-managed inline styles and
        // classes back to template defaults on every value change,
        // so we must re-establish the display state here.
        showLoading();

        var img = new Image();
        img.onload = function () {
            state.image = img;
            state.annotations = data.annotations || [];
            state.visibility = [];
            for (var i = 0; i < state.annotations.length; i++) {
                state.visibility.push(true);
            }
            state.selectedIndex = -1;

            // Build label visibility map
            state.labelVisibility = {};
            for (var i = 0; i < state.annotations.length; i++) {
                var lbl = state.annotations[i].label;
                if (lbl && !state.labelVisibility.hasOwnProperty(lbl)) {
                    state.labelVisibility[lbl] = true;
                }
            }

            // Decode RLE masks to offscreen canvases
            state.maskImages = [];
            for (var i = 0; i < state.annotations.length; i++) {
                if (state.annotations[i].mask) {
                    state.maskImages[i] = createMaskCanvas(state.annotations[i].mask, state.annotations[i].color);
                } else {
                    state.maskImages[i] = null;
                }
            }

            showContent();
            state.sortMode = getDefaultSortMode();
            requestAnimationFrame(function () {
                fitCanvas();
                state.zoom = 1;
                state.panX = 0;
                state.panY = 0;
                render();
                renderControlPanel();
            });
        };
        img.src = data.image;
    }

    // ── Display States: placeholder / loading / content ──────────

    function showPlaceholder() {
        state.image = null;
        state.annotations = [];
        state.visibility = [];
        state.selectedIndex = -1;
        canvasWrapper.style.display = "none";
        loadingIndicator.classList.remove("visible");
        controlPanel.classList.remove("visible");
        tooltip.classList.remove("visible");
        placeholder.classList.remove("hidden");
    }

    function showLoading() {
        placeholder.classList.add("hidden");
        canvasWrapper.style.display = "none";
        controlPanel.classList.remove("visible");
        loadingIndicator.classList.add("visible");
    }

    function showContent() {
        placeholder.classList.add("hidden");
        loadingIndicator.classList.remove("visible");
        canvasWrapper.style.display = "flex";
    }

    // ── Canvas Sizing ─────────────────────────────────────────────

    function fitCanvas() {
        if (!state.image) return;

        var img = state.image;

        if (state.maximized) {
            var wrapperW = canvasWrapper.clientWidth || window.innerWidth;
            var wrapperH = canvasWrapper.clientHeight || window.innerHeight;

            var w = wrapperW;
            var h = img.naturalHeight * (w / img.naturalWidth);

            if (h > wrapperH) {
                h = wrapperH;
                w = img.naturalWidth * (h / img.naturalHeight);
            }

            canvas.width = Math.round(w);
            canvas.height = Math.round(h);
        } else {
            var maxWidth = canvasWrapper.clientWidth || 800;

            var w = maxWidth;
            var h = img.naturalHeight * (w / img.naturalWidth);

            if (h > MAX_CANVAS_HEIGHT) {
                h = MAX_CANVAS_HEIGHT;
                w = img.naturalWidth * (h / img.naturalHeight);
            }

            canvas.width = Math.round(w);
            canvas.height = Math.round(h);
        }
        state.scale = canvas.width / img.naturalWidth;
    }

    // ── Zoom/Pan Helpers ────────────────────────────────────────────

    function clientToCanvas(clientX, clientY) {
        var rect = canvas.getBoundingClientRect();
        var cssX = (clientX - rect.left) * (canvas.width / rect.width);
        var cssY = (clientY - rect.top) * (canvas.height / rect.height);
        return {
            x: (cssX - state.panX) / state.zoom,
            y: (cssY - state.panY) / state.zoom
        };
    }

    function clampPan() {
        if (state.zoom <= 1) {
            state.panX = 0;
            state.panY = 0;
            return;
        }
        var maxPanX = 0;
        var minPanX = canvas.width - canvas.width * state.zoom;
        var maxPanY = 0;
        var minPanY = canvas.height - canvas.height * state.zoom;
        if (state.panX > maxPanX) state.panX = maxPanX;
        if (state.panX < minPanX) state.panX = minPanX;
        if (state.panY > maxPanY) state.panY = maxPanY;
        if (state.panY < minPanY) state.panY = minPanY;
    }

    function zoomToCenter(newZoom) {
        newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
        var cx = canvas.width / 2;
        var cy = canvas.height / 2;
        state.panX = cx - (cx - state.panX) * (newZoom / state.zoom);
        state.panY = cy - (cy - state.panY) * (newZoom / state.zoom);
        state.zoom = newZoom;
        clampPan();
        render();
    }

    function resetZoom() {
        state.zoom = 1;
        state.panX = 0;
        state.panY = 0;
        render();
    }

    function resetToDefaults() {
        if (!state.image) return;

        state.showImage = true;
        toggleImageBtn.classList.add("active");

        state.layers.masks = true;
        state.layers.boxes = true;
        state.layers.skeleton = true;
        state.layers.keypoints = true;

        state.thresholdMin = initialScoreThresholdMin;
        state.thresholdMax = initialScoreThresholdMax;
        state.keypointThreshold = initialKeypointThreshold;
        state.keypointRadius = initialKeypointRadius;
        state.connectionWidth = CONNECTION_WIDTH;
        state.maskAlpha = MASK_ALPHA;
        state.connectionAlpha = CONNECTION_ALPHA;
        state.bboxLineWidth = BBOX_LINE_WIDTH;

        for (var label in state.labelVisibility) {
            if (state.labelVisibility.hasOwnProperty(label)) {
                state.labelVisibility[label] = true;
            }
        }
        for (var i = 0; i < state.visibility.length; i++) {
            state.visibility[i] = true;
        }

        state.selectedIndex = -1;
        state.expandedIndex = -1;
        state.sortMode = getDefaultSortMode();
        state.zoom = 1;
        state.panX = 0;
        state.panY = 0;
        canvas.style.cursor = "default";

        render();
        renderControlPanel();
    }

    // ── Rendering ─────────────────────────────────────────────────

    function isAnnotationVisible(i) {
        if (!state.visibility[i]) return false;
        var ann = state.annotations[i];
        if (ann.score != null && (ann.score < state.thresholdMin || ann.score > state.thresholdMax)) return false;
        if (ann.label && state.labelVisibility.hasOwnProperty(ann.label) && !state.labelVisibility[ann.label]) return false;
        return true;
    }

    function isKeypointVisible(kp) {
        if (kp.x == null || kp.y == null) return false;
        if (kp.confidence != null && kp.confidence < state.keypointThreshold) return false;
        return true;
    }

    function render() {
        if (!state.image) return;

        // Clear in screen space
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        // Dark background visible when zoomed/panned
        if (state.zoom > 1) {
            ctx.fillStyle = "#1a1a1a";
            ctx.fillRect(0, 0, canvas.width, canvas.height);
        }

        // Apply zoom+pan transform
        ctx.setTransform(state.zoom, 0, 0, state.zoom, state.panX, state.panY);

        if (state.showImage) {
            ctx.drawImage(state.image, 0, 0, canvas.width, canvas.height);
        } else {
            ctx.fillStyle = "#2a2a2a";
            ctx.fillRect(0, 0, canvas.width, canvas.height);
        }

        var hasSelection = state.selectedIndex >= 0;

        // Draw order: masks (back) → bbox → connections → keypoints (front)
        for (var pass = 0; pass < 4; pass++) {
            for (var i = 0; i < state.annotations.length; i++) {
                if (!isAnnotationVisible(i)) continue;

                var ann = state.annotations[i];
                var dim = hasSelection && i !== state.selectedIndex;

                ctx.save();
                if (dim) ctx.globalAlpha = DIM_ALPHA;

                if (pass === 0 && state.layers.masks) drawMask(i);
                else if (pass === 1 && state.layers.boxes) drawBbox(ann);
                else if (pass === 2 && state.layers.skeleton) drawConnections(ann);
                else if (pass === 3 && state.layers.keypoints) drawKeypoints(ann);

                ctx.restore();
            }
        }

        // Draw zoom indicator in screen space
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        if (state.zoom > 1) {
            var zoomText = Math.round(state.zoom * 100) + "%";
            ctx.font = "bold 12px -apple-system, BlinkMacSystemFont, sans-serif";
            var tm = ctx.measureText(zoomText);
            var px = canvas.width - tm.width - 12;
            var py = 8;
            ctx.fillStyle = "rgba(0,0,0,0.5)";
            ctx.beginPath();
            ctx.roundRect(px - 6, py - 2, tm.width + 12, 20, 4);
            ctx.fill();
            ctx.fillStyle = "#fff";
            ctx.textBaseline = "top";
            ctx.fillText(zoomText, px, py + 2);
        }
    }

    function drawMask(i) {
        var maskImg = state.maskImages[i];
        if (!maskImg) return;
        var baseAlpha = ctx.globalAlpha;
        ctx.globalAlpha = baseAlpha * state.maskAlpha;
        ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height);
        ctx.globalAlpha = baseAlpha;
    }

    function drawBbox(ann) {
        var bbox = ann.bbox;
        if (!bbox) return;

        var x = bbox.x * state.scale;
        var y = bbox.y * state.scale;
        var w = bbox.width * state.scale;
        var h = bbox.height * state.scale;
        var baseAlpha = ctx.globalAlpha;
        var iz = 1 / state.zoom;

        ctx.strokeStyle = ann.color;
        ctx.lineWidth = state.bboxLineWidth * iz;
        ctx.strokeRect(x, y, w, h);

        // Label + score text
        var labelText = ann.label || "";
        if (ann.score != null) {
            labelText += (labelText ? " " : "") + (ann.score * 100).toFixed(1) + "%";
        }
        if (labelText) {
            var fontSize = 11 * iz;
            ctx.font = "bold " + fontSize + "px -apple-system, BlinkMacSystemFont, sans-serif";
            var textMetrics = ctx.measureText(labelText);
            var textH = 16 * iz;
            var pad = 4 * iz;
            var bgW = textMetrics.width + pad * 2;
            var bgH = textH + pad;

            // Place label above bbox; if clipped, place inside top edge
            var labelAbove = y - bgH >= 0;
            var bgY = labelAbove ? y - bgH : y;
            var textY = labelAbove ? y - pad / 2 : y + bgH - pad / 2;

            // Semi-transparent background
            ctx.fillStyle = ann.color;
            ctx.globalAlpha = baseAlpha * 0.7;
            ctx.fillRect(x, bgY, bgW, bgH);

            // White text
            ctx.globalAlpha = baseAlpha;
            ctx.fillStyle = "#ffffff";
            ctx.textBaseline = "bottom";
            ctx.fillText(labelText, x + pad, textY);
        }
    }

    function drawConnections(ann) {
        var kps = ann.keypoints;
        var conns = ann.connections;
        if (!conns || conns.length === 0 || !kps || kps.length === 0) return;

        var baseAlpha = ctx.globalAlpha;
        ctx.strokeStyle = ann.color;
        ctx.lineWidth = state.connectionWidth / state.zoom;
        ctx.globalAlpha = baseAlpha * state.connectionAlpha;

        for (var i = 0; i < conns.length; i++) {
            var idxA = conns[i][0];
            var idxB = conns[i][1];
            if (idxA < 0 || idxA >= kps.length || idxB < 0 || idxB >= kps.length) continue;

            var a = kps[idxA];
            var b = kps[idxB];

            if (!isKeypointVisible(a) || !isKeypointVisible(b)) continue;

            ctx.beginPath();
            ctx.moveTo(a.x * state.scale, a.y * state.scale);
            ctx.lineTo(b.x * state.scale, b.y * state.scale);
            ctx.stroke();
        }

        ctx.globalAlpha = baseAlpha;
    }

    function drawKeypoints(ann) {
        var kps = ann.keypoints;
        if (!kps || kps.length === 0) return;

        var iz = 1 / state.zoom;
        for (var i = 0; i < kps.length; i++) {
            var kp = kps[i];
            if (!isKeypointVisible(kp)) continue;

            var cx = kp.x * state.scale;
            var cy = kp.y * state.scale;

            // White border
            ctx.beginPath();
            ctx.arc(cx, cy, (state.keypointRadius + 1) * iz, 0, 2 * Math.PI);
            ctx.fillStyle = "#ffffff";
            ctx.fill();

            // Colored fill
            ctx.beginPath();
            ctx.arc(cx, cy, state.keypointRadius * iz, 0, 2 * Math.PI);
            ctx.fillStyle = ann.color;
            ctx.fill();
        }
    }

    // ── RLE Decode ──────────────────────────────────────────────────

    function createMaskCanvas(rle, colorHex) {
        var counts = rle.counts;
        var h = rle.size[0], w = rle.size[1];
        var r = parseInt(colorHex.slice(1, 3), 16);
        var g = parseInt(colorHex.slice(3, 5), 16);
        var b = parseInt(colorHex.slice(5, 7), 16);
        var offscreen = document.createElement("canvas");
        offscreen.width = w;
        offscreen.height = h;
        var offCtx = offscreen.getContext("2d");
        var imageData = offCtx.createImageData(w, h);
        var data = imageData.data;
        // RLE is column-major (COCO format), convert to row-major ImageData
        var pos = 0;
        for (var i = 0; i < counts.length; i++) {
            var c = counts[i];
            if (i % 2 === 1) {
                var end = pos + c;
                for (var j = pos; j < end; j++) {
                    var row = j % h;
                    var col = (j / h) | 0;
                    var idx = (row * w + col) * 4;
                    data[idx] = r;
                    data[idx + 1] = g;
                    data[idx + 2] = b;
                    data[idx + 3] = 255;
                }
            }
            pos += c;
        }
        offCtx.putImageData(imageData, 0, 0);
        return offscreen;
    }

    // ── Helpers ─────────────────────────────────────────────────────

    function buildAnnotationSummary(ann) {
        var parts = [];
        if (ann.mask) {
            parts.push("mask");
        }
        if (ann.bbox) {
            parts.push("bbox");
        }
        var kps = ann.keypoints || [];
        if (kps.length > 0) {
            var validCount = 0;
            for (var j = 0; j < kps.length; j++) {
                if (isKeypointVisible(kps[j])) validCount++;
            }
            parts.push(validCount + " pts");
        }
        if (ann.score != null) {
            parts.push((ann.score * 100).toFixed(1) + "%");
        }
        return parts.join(", ") || "empty";
    }

    function escapeHtml(text) {
        var div = document.createElement("div");
        div.textContent = text;
        return div.innerHTML;
    }

    function getLabelColor(label) {
        // Return the color of the first annotation with this label
        for (var i = 0; i < state.annotations.length; i++) {
            if (state.annotations[i].label === label) {
                return state.annotations[i].color;
            }
        }
        return "#888";
    }

    function getDefaultSortMode() {
        var hasScores = false;
        var hasBboxes = false;
        for (var i = 0; i < state.annotations.length; i++) {
            if (state.annotations[i].score != null) hasScores = true;
            if (state.annotations[i].bbox) hasBboxes = true;
            if (hasScores && hasBboxes) break;
        }
        if (hasScores) return "score-desc";
        if (hasBboxes) return "size-desc";
        return "none";
    }

    function bboxArea(ann) {
        if (!ann.bbox) return null;
        return ann.bbox.width * ann.bbox.height;
    }

    function computeSortedIndices() {
        var n = state.annotations.length;
        var indices = [];
        for (var i = 0; i < n; i++) indices.push(i);

        var mode = state.sortMode;
        if (mode === "score-desc" || mode === "score-asc") {
            var dir = mode === "score-desc" ? -1 : 1;
            indices.sort(function (a, b) {
                var sa = state.annotations[a].score;
                var sb = state.annotations[b].score;
                var hasA = sa != null;
                var hasB = sb != null;
                if (hasA && hasB) return (sa - sb) * dir || a - b;
                if (hasA) return -1;
                if (hasB) return 1;
                return a - b;
            });
        } else if (mode === "size-desc" || mode === "size-asc") {
            var dir = mode === "size-desc" ? -1 : 1;
            indices.sort(function (a, b) {
                var aa = bboxArea(state.annotations[a]);
                var ab = bboxArea(state.annotations[b]);
                var hasA = aa != null;
                var hasB = ab != null;
                if (hasA && hasB) return (aa - ab) * dir || a - b;
                if (hasA) return -1;
                if (hasB) return 1;
                return a - b;
            });
        }

        // Stable partition: visible first, hidden last (only when some label filter is OFF)
        var anyLabelFiltered = false;
        var labels = Object.keys(state.labelVisibility);
        for (var li = 0; li < labels.length; li++) {
            if (!state.labelVisibility[labels[li]]) { anyLabelFiltered = true; break; }
        }
        if (anyLabelFiltered) {
            var visible = [];
            var hidden = [];
            for (var j = 0; j < indices.length; j++) {
                var idx = indices[j];
                var ann = state.annotations[idx];
                var labelHidden = ann.label && state.labelVisibility.hasOwnProperty(ann.label) && !state.labelVisibility[ann.label];
                if (labelHidden) hidden.push(idx);
                else visible.push(idx);
            }
            indices = visible.concat(hidden);
        }

        state.sortedIndices = indices;
    }

    function handleSortToggle(e) {
        var key = e.currentTarget.getAttribute("data-sort-key");
        if (!key) return;
        // Toggle direction if same key is already active
        if (state.sortMode === key + "-desc") {
            state.sortMode = key + "-asc";
        } else if (state.sortMode === key + "-asc") {
            state.sortMode = key + "-desc";
        } else {
            state.sortMode = key + "-desc";
        }
        computeSortedIndices();
        renderControlPanel();
    }

    function updateHeaderStats() {
        if (!countEl) return;

        var total = state.annotations.length;
        var visibleCount = 0;
        var minScore = Infinity;
        var maxScore = -Infinity;
        var hasScores = false;

        for (var i = 0; i < state.annotations.length; i++) {
            if (!isAnnotationVisible(i)) continue;
            visibleCount++;
            var ann = state.annotations[i];
            if (ann.score != null) {
                hasScores = true;
                if (ann.score < minScore) minScore = ann.score;
                if (ann.score > maxScore) maxScore = ann.score;
            }
        }

        var text = visibleCount + " / " + total;
        if (hasScores && visibleCount > 0) {
            text += " \u00B7 " + (minScore * 100).toFixed(0) + "\u2013" + (maxScore * 100).toFixed(0) + "%";
        }
        countEl.textContent = text;
    }

    // ── Control Panel ─────────────────────────────────────────────

    function renderControlPanel() {
        if (state.annotations.length === 0) {
            controlPanel.classList.remove("visible");
            annotationList.innerHTML = "";
            return;
        }

        computeSortedIndices();

        var html = "";

        // Detect which layer types are present in annotations
        var availableLayers = [];
        var hasMasks = false, hasBoxes = false, hasSkeleton = false, hasKeypoints = false;
        for (var i = 0; i < state.annotations.length; i++) {
            var ann = state.annotations[i];
            if (ann.mask) hasMasks = true;
            if (ann.bbox) hasBoxes = true;
            if (ann.connections && ann.connections.length > 0) hasSkeleton = true;
            if (ann.keypoints && ann.keypoints.length > 0) hasKeypoints = true;
        }
        if (hasMasks) availableLayers.push("masks");
        if (hasBoxes) availableLayers.push("boxes");
        if (hasSkeleton) availableLayers.push("skeleton");
        if (hasKeypoints) availableLayers.push("keypoints");

        var layerLabels = { masks: "Masks", boxes: "Boxes", skeleton: "Skeleton", keypoints: "Keypoints" };

        // Only show layer toggles when 2+ types are present
        if (availableLayers.length >= 2) {
            html += '<div class="layer-toggles">';
            for (var i = 0; i < availableLayers.length; i++) {
                var layer = availableLayers[i];
                html += '<button class="layer-btn' + (state.layers[layer] ? ' active' : '') + '" data-layer="' + layer + '">' + layerLabels[layer] + '</button>';
            }
            html += '</div>';
        }

        // Label filters with counts
        var labels = Object.keys(state.labelVisibility);
        if (labels.length > 0) {
            var labelTotalCounts = {};
            for (var i = 0; i < state.annotations.length; i++) {
                var lbl = state.annotations[i].label;
                if (lbl) labelTotalCounts[lbl] = (labelTotalCounts[lbl] || 0) + 1;
            }
            html += '<div class="label-filters">';
            html += '<span class="label-filters-title">Labels</span>';
            for (var li = 0; li < labels.length; li++) {
                var lbl = labels[li];
                var lblActive = state.labelVisibility[lbl];
                var lblColor = getLabelColor(lbl);
                html += '<button class="label-filter-btn' + (lblActive ? ' active' : '') + '" data-label="' + escapeHtml(lbl) + '">';
                html += '<span class="label-color-dot" style="background:' + lblColor + '"></span>';
                html += escapeHtml(lbl) + ' <span class="label-count">' + (labelTotalCounts[lbl] || 0) + '</span>';
                html += '</button>';
            }
            html += '</div>';
        }

        // Sort controls
        var hasScoresForSort = false;
        var hasBboxesForSort = false;
        for (var si = 0; si < state.annotations.length; si++) {
            if (state.annotations[si].score != null) hasScoresForSort = true;
            if (state.annotations[si].bbox) hasBboxesForSort = true;
        }
        if (hasScoresForSort || hasBboxesForSort) {
            var sortButtons = [];
            if (hasScoresForSort) sortButtons.push("score");
            if (hasBboxesForSort) sortButtons.push("size");
            // Only show controls when 2+ sort options exist
            if (sortButtons.length >= 2) {
                var sortLabels = { score: "Score", size: "Size" };
                html += '<div class="sort-controls">';
                html += '<span class="sort-controls-title">Sort</span>';
                for (var sbi = 0; sbi < sortButtons.length; sbi++) {
                    var sk = sortButtons[sbi];
                    var isDesc = state.sortMode === sk + "-desc";
                    var isAsc = state.sortMode === sk + "-asc";
                    var isActive = isDesc || isAsc;
                    var arrow = isActive ? (isDesc ? " \u25BC" : " \u25B2") : "";
                    html += '<button class="sort-btn' + (isActive ? ' active' : '') + '" data-sort-key="' + sk + '">' + sortLabels[sk] + arrow + '</button>';
                }
                html += '</div>';
            }
        }

        // Dual-thumb score threshold slider
        html += '<div class="threshold-row">';
        html += '<span>Score</span>';
        html += '<div class="dual-range-wrapper">';
        html += '<input type="range" class="threshold-slider-min" min="0" max="100" value="' + Math.round(state.thresholdMin * 100) + '">';
        html += '<input type="range" class="threshold-slider-max" min="0" max="100" value="' + Math.round(state.thresholdMax * 100) + '">';
        html += '</div>';
        html += '<span class="threshold-value">' + Math.round(state.thresholdMin * 100) + '%\u2013' + Math.round(state.thresholdMax * 100) + '%</span>';
        html += '</div>';

        // Keypoint threshold slider (only when annotations have keypoints)
        if (hasKeypoints) {
            html += '<div class="threshold-row">';
            html += '<span>Keypoint ≥</span>';
            html += '<input type="range" class="keypoint-threshold-slider" min="0" max="100" value="' + Math.round(state.keypointThreshold * 100) + '">';
            html += '<span class="keypoint-threshold-value">' + Math.round(state.keypointThreshold * 100) + '%</span>';
            html += '</div>';
        }

        // Collapsible draw options section
        var drawOptionsHtml = '';
        if (hasMasks) {
            drawOptionsHtml += '<div class="threshold-row">';
            drawOptionsHtml += '<span>Mask Opacity</span>';
            drawOptionsHtml += '<input type="range" class="mask-alpha-slider" min="0" max="100" step="5" value="' + Math.round(state.maskAlpha * 100) + '">';
            drawOptionsHtml += '<span class="slider-value mask-alpha-value">' + Math.round(state.maskAlpha * 100) + '%</span>';
            drawOptionsHtml += '</div>';
        }
        if (hasKeypoints) {
            drawOptionsHtml += '<div class="threshold-row">';
            drawOptionsHtml += '<span>Keypoint Size</span>';
            drawOptionsHtml += '<input type="range" class="keypoint-radius-slider" min="1" max="20" step="1" value="' + state.keypointRadius + '">';
            drawOptionsHtml += '<span class="slider-value keypoint-radius-value">' + state.keypointRadius + '</span>';
            drawOptionsHtml += '</div>';
        }
        if (hasSkeleton) {
            drawOptionsHtml += '<div class="threshold-row">';
            drawOptionsHtml += '<span>Line Width</span>';
            drawOptionsHtml += '<input type="range" class="connection-width-slider" min="1" max="10" step="1" value="' + state.connectionWidth + '">';
            drawOptionsHtml += '<span class="slider-value connection-width-value">' + state.connectionWidth + '</span>';
            drawOptionsHtml += '</div>';
            drawOptionsHtml += '<div class="threshold-row">';
            drawOptionsHtml += '<span>Line Opacity</span>';
            drawOptionsHtml += '<input type="range" class="connection-alpha-slider" min="0" max="100" step="5" value="' + Math.round(state.connectionAlpha * 100) + '">';
            drawOptionsHtml += '<span class="slider-value connection-alpha-value">' + Math.round(state.connectionAlpha * 100) + '%</span>';
            drawOptionsHtml += '</div>';
        }
        if (hasBoxes) {
            drawOptionsHtml += '<div class="threshold-row">';
            drawOptionsHtml += '<span>Box Width</span>';
            drawOptionsHtml += '<input type="range" class="bbox-line-width-slider" min="1" max="10" step="1" value="' + state.bboxLineWidth + '">';
            drawOptionsHtml += '<span class="slider-value bbox-line-width-value">' + state.bboxLineWidth + '</span>';
            drawOptionsHtml += '</div>';
        }
        if (drawOptionsHtml) {
            html += '<div class="draw-options' + (state.drawOptionsOpen ? ' open' : '') + '">';
            html += '<button class="draw-options-toggle">Draw Options <span class="draw-options-arrow">▶</span></button>';
            html += '<div class="draw-options-body">' + drawOptionsHtml + '</div>';
            html += '</div>';
        }

        // Select-all row + scrollable annotation rows container
        html += '<div class="annotation-rows">';

        var allChecked = true;
        var anyChecked = false;
        for (var i = 0; i < state.visibility.length; i++) {
            if (state.visibility[i]) anyChecked = true;
            else allChecked = false;
        }
        html += '<div class="select-all-row">';
        html += '<input type="checkbox" class="select-all-checkbox"' + (anyChecked ? ' checked' : '') + '>';
        html += '<span class="select-all-label">All</span>';
        html += '</div>';

        // Annotation rows (iterate in sorted/grouped order)
        var anyLabelFiltered = false;
        var labelKeys = Object.keys(state.labelVisibility);
        for (var li = 0; li < labelKeys.length; li++) {
            if (!state.labelVisibility[labelKeys[li]]) { anyLabelFiltered = true; break; }
        }
        var separatorInserted = false;

        for (var si = 0; si < state.sortedIndices.length; si++) {
            var i = state.sortedIndices[si];
            var ann = state.annotations[i];
            var visible = state.visibility[i];
            var selected = state.selectedIndex === i;
            var summary = buildAnnotationSummary(ann);
            var outsideRange = ann.score != null && (ann.score < state.thresholdMin || ann.score > state.thresholdMax);
            var labelHidden = ann.label && state.labelVisibility.hasOwnProperty(ann.label) && !state.labelVisibility[ann.label];

            // Insert separator before the first label-hidden item
            if (anyLabelFiltered && labelHidden && !separatorInserted) {
                html += '<div class="annotation-group-separator"><span>Hidden</span></div>';
                separatorInserted = true;
            }

            var expanded = state.expandedIndex === i;

            html += '<div class="annotation-row' + (selected ? ' selected' : '') + (outsideRange ? ' below-threshold' : '') + (labelHidden ? ' filtered-out' : '') + '" data-index="' + i + '">';
            html += '<span class="ann-dot" style="background:' + ann.color + '"></span>';
            html += '<input type="checkbox" class="ann-checkbox" data-index="' + i + '"' + (visible ? ' checked' : '') + '>';
            html += '<span class="ann-label">' + escapeHtml(ann.label) + '</span>';
            html += '<span class="ann-summary">' + escapeHtml(summary) + '</span>';
            html += '<button class="ann-expand' + (expanded ? ' expanded' : '') + '" data-index="' + i + '">▶</button>';
            html += '</div>';

            // Detail panel (shown when expand button is clicked)
            html += '<div class="annotation-detail' + (expanded ? ' visible' : '') + '" data-index="' + i + '">';
            html += buildDetailHtml(ann);
            html += '</div>';
        }

        html += '</div>'; // close .annotation-rows

        annotationList.innerHTML = html;
        controlPanel.classList.add("visible");
        updateHeaderStats();

        // Initialize dual-range track highlight
        var dualWrapper = annotationList.querySelector(".dual-range-wrapper");
        if (dualWrapper) updateDualRangeTrack(dualWrapper);

        // Bind layer toggle events
        var layerBtns = annotationList.querySelectorAll(".layer-btn");
        for (var i = 0; i < layerBtns.length; i++) {
            layerBtns[i].addEventListener("click", handleLayerToggle);
        }

        // Bind label filter events (single-click: toggle, double-click: solo)
        var labelBtns = annotationList.querySelectorAll(".label-filter-btn");
        for (var i = 0; i < labelBtns.length; i++) {
            labelBtns[i].addEventListener("click", handleLabelFilterClick);
            labelBtns[i].addEventListener("dblclick", handleLabelFilterDblClick);
        }

        // Bind sort button events
        var sortBtns = annotationList.querySelectorAll(".sort-btn");
        for (var i = 0; i < sortBtns.length; i++) {
            sortBtns[i].addEventListener("click", handleSortToggle);
        }

        // Bind dual threshold sliders
        var sliderMin = annotationList.querySelector(".threshold-slider-min");
        var sliderMax = annotationList.querySelector(".threshold-slider-max");
        if (sliderMin) sliderMin.addEventListener("input", handleThresholdMinChange);
        if (sliderMax) sliderMax.addEventListener("input", handleThresholdMaxChange);

        // Bind keypoint threshold slider
        var kpSlider = annotationList.querySelector(".keypoint-threshold-slider");
        if (kpSlider) {
            kpSlider.addEventListener("input", handleKeypointThresholdChange);
        }

        // Bind draw options toggle
        var drawToggle = annotationList.querySelector(".draw-options-toggle");
        if (drawToggle) {
            drawToggle.addEventListener("click", function () {
                var section = drawToggle.closest(".draw-options");
                section.classList.toggle("open");
                state.drawOptionsOpen = section.classList.contains("open");
            });
        }

        // Bind visual parameter sliders
        var maSlider = annotationList.querySelector(".mask-alpha-slider");
        if (maSlider) {
            maSlider.addEventListener("input", handleMaskAlphaChange);
        }
        var krSlider = annotationList.querySelector(".keypoint-radius-slider");
        if (krSlider) {
            krSlider.addEventListener("input", handleKeypointRadiusChange);
        }
        var cwSlider = annotationList.querySelector(".connection-width-slider");
        if (cwSlider) {
            cwSlider.addEventListener("input", handleConnectionWidthChange);
        }
        var caSlider = annotationList.querySelector(".connection-alpha-slider");
        if (caSlider) {
            caSlider.addEventListener("input", handleConnectionAlphaChange);
        }
        var blwSlider = annotationList.querySelector(".bbox-line-width-slider");
        if (blwSlider) {
            blwSlider.addEventListener("input", handleBboxLineWidthChange);
        }

        // Initialize single slider track fills
        var singleSliders = annotationList.querySelectorAll('.threshold-row input[type="range"]:not(.threshold-slider-min):not(.threshold-slider-max)');
        for (var i = 0; i < singleSliders.length; i++) {
            updateSliderTrack(singleSliders[i]);
        }

        // Bind select-all checkbox
        var selectAllCb = annotationList.querySelector(".select-all-checkbox");
        if (selectAllCb) {
            // Set indeterminate state: some checked but not all
            if (anyChecked && !allChecked) {
                selectAllCb.indeterminate = true;
            }
            selectAllCb.addEventListener("change", function (e) {
                var newVal = e.target.checked;
                for (var i = 0; i < state.visibility.length; i++) {
                    state.visibility[i] = newVal;
                }
                render();
                renderControlPanel();
            });
        }

        // Bind checkbox events
        var checkboxes = annotationList.querySelectorAll(".ann-checkbox");
        for (var i = 0; i < checkboxes.length; i++) {
            checkboxes[i].addEventListener("change", handleCheckboxChange);
            checkboxes[i].addEventListener("click", function (e) { e.stopPropagation(); });
        }

        // Bind expand button events
        var expandBtns = annotationList.querySelectorAll(".ann-expand");
        for (var i = 0; i < expandBtns.length; i++) {
            expandBtns[i].addEventListener("click", handleExpandClick);
        }

        // Bind row click events
        var rows = annotationList.querySelectorAll(".annotation-row");
        for (var i = 0; i < rows.length; i++) {
            rows[i].addEventListener("click", handleRowClick);
        }
    }

    function buildDetailHtml(ann) {
        var html = "";
        if (ann.bbox) {
            html += '<div class="detail-section-title">Bounding Box</div>';
            html += '<table>';
            html += '<tr><td>Position:</td><td>' + ann.bbox.x.toFixed(1) + ', ' + ann.bbox.y.toFixed(1) + '</td></tr>';
            html += '<tr><td>Size:</td><td>' + ann.bbox.width.toFixed(1) + ' × ' + ann.bbox.height.toFixed(1) + '</td></tr>';
            if (ann.score != null) {
                html += '<tr><td>Score:</td><td>' + (ann.score * 100).toFixed(1) + '%</td></tr>';
            }
            html += '</table>';
        }
        var kps = ann.keypoints || [];
        if (kps.length > 0) {
            html += '<div class="detail-section-title">Keypoints</div>';
            html += '<table>';
            for (var j = 0; j < kps.length; j++) {
                var kp = kps[j];
                var name = kp.name || ("kp" + j);
                var coords = (kp.x != null && kp.y != null) ? kp.x.toFixed(1) + ', ' + kp.y.toFixed(1) : "missing";
                var conf = kp.confidence != null ? (kp.confidence * 100).toFixed(1) + '%' : "";
                html += '<tr><td>' + escapeHtml(name) + '</td><td>' + coords + '</td><td>' + conf + '</td></tr>';
            }
            html += '</table>';
        }
        return html || '<span>No details available</span>';
    }

    // ── Event Handlers ─────────────────────────────────────────────

    function handleLayerToggle(e) {
        var layer = e.target.getAttribute("data-layer");
        if (layer && state.layers.hasOwnProperty(layer)) {
            state.layers[layer] = !state.layers[layer];
            render();
            renderControlPanel();
        }
    }

    var labelClickTimer = null;

    function handleLabelFilterClick(e) {
        var label = e.currentTarget.getAttribute("data-label");
        if (!label || !state.labelVisibility.hasOwnProperty(label)) return;
        if (labelClickTimer) clearTimeout(labelClickTimer);
        labelClickTimer = setTimeout(function () {
            labelClickTimer = null;
            var newVal = !state.labelVisibility[label];
            state.labelVisibility[label] = newVal;
            for (var i = 0; i < state.annotations.length; i++) {
                if (state.annotations[i].label === label) {
                    state.visibility[i] = newVal;
                }
            }
            render();
            renderControlPanel();
        }, 200);
    }

    function handleLabelFilterDblClick(e) {
        e.preventDefault();
        if (labelClickTimer) {
            clearTimeout(labelClickTimer);
            labelClickTimer = null;
        }
        var label = e.currentTarget.getAttribute("data-label");
        if (!label || !state.labelVisibility.hasOwnProperty(label)) return;

        // Check if this label is already solo (only this one is ON)
        var labels = Object.keys(state.labelVisibility);
        var onlyThisOn = labels.every(function (l) {
            return l === label ? state.labelVisibility[l] : !state.labelVisibility[l];
        });

        if (onlyThisOn) {
            // Unsolo: turn all labels ON
            for (var li = 0; li < labels.length; li++) {
                state.labelVisibility[labels[li]] = true;
            }
            for (var i = 0; i < state.annotations.length; i++) {
                state.visibility[i] = true;
            }
        } else {
            // Solo: turn only this label ON
            for (var li = 0; li < labels.length; li++) {
                state.labelVisibility[labels[li]] = (labels[li] === label);
            }
            for (var i = 0; i < state.annotations.length; i++) {
                state.visibility[i] = (state.annotations[i].label === label);
            }
        }
        render();
        renderControlPanel();
    }

    function updateThresholdUI() {
        render();
        var label = annotationList.querySelector(".threshold-value");
        if (label) label.textContent = Math.round(state.thresholdMin * 100) + '%\u2013' + Math.round(state.thresholdMax * 100) + '%';
        // Update row opacity for outside-range items
        var rows = annotationList.querySelectorAll(".annotation-row");
        for (var i = 0; i < rows.length; i++) {
            var idx = parseInt(rows[i].getAttribute("data-index"), 10);
            var ann = state.annotations[idx];
            var outsideRange = ann.score != null && (ann.score < state.thresholdMin || ann.score > state.thresholdMax);
            if (outsideRange) {
                rows[i].classList.add("below-threshold");
            } else {
                rows[i].classList.remove("below-threshold");
            }
        }
        // Update track highlight
        var wrapper = annotationList.querySelector(".dual-range-wrapper");
        if (wrapper) updateDualRangeTrack(wrapper);
        updateHeaderStats();
    }

    function handleThresholdMinChange(e) {
        var val = parseInt(e.target.value, 10) / 100;
        state.thresholdMin = Math.min(val, state.thresholdMax);
        e.target.value = Math.round(state.thresholdMin * 100);
        updateThresholdUI();
    }

    function handleThresholdMaxChange(e) {
        var val = parseInt(e.target.value, 10) / 100;
        state.thresholdMax = Math.max(val, state.thresholdMin);
        e.target.value = Math.round(state.thresholdMax * 100);
        updateThresholdUI();
    }

    function updateSliderTrack(slider) {
        var min = parseFloat(slider.min) || 0;
        var max = parseFloat(slider.max) || 100;
        var val = parseFloat(slider.value) || 0;
        var pct = ((val - min) / (max - min)) * 100;
        var t = 'calc(50% - 2px)';
        var b = 'calc(50% + 2px)';
        slider.style.background =
            'linear-gradient(to bottom, transparent ' + t + ', var(--color-accent, #2196F3) ' + t + ', var(--color-accent, #2196F3) ' + b + ', transparent ' + b + ') 0 0 / ' + pct + '% 100% no-repeat, ' +
            'linear-gradient(to bottom, transparent ' + t + ', var(--border-color-primary, #d0d0d0) ' + t + ', var(--border-color-primary, #d0d0d0) ' + b + ', transparent ' + b + ')';
    }

    function updateDualRangeTrack(wrapper) {
        var minVal = Math.round(state.thresholdMin * 100);
        var maxVal = Math.round(state.thresholdMax * 100);
        wrapper.style.background = 'linear-gradient(to right, var(--border-color-primary, #d0d0d0) ' + minVal + '%, var(--color-accent, #2196F3) ' + minVal + '%, var(--color-accent, #2196F3) ' + maxVal + '%, var(--border-color-primary, #d0d0d0) ' + maxVal + '%)';
    }

    function handleKeypointThresholdChange(e) {
        state.keypointThreshold = parseInt(e.target.value, 10) / 100;
        render();
        updateSliderTrack(e.target);
        // Update keypoint threshold label
        var label = annotationList.querySelector(".keypoint-threshold-value");
        if (label) label.textContent = Math.round(state.keypointThreshold * 100) + '%';
        updateHeaderStats();
        // Update annotation summaries
        var summaryEls = annotationList.querySelectorAll(".ann-summary");
        for (var i = 0; i < summaryEls.length; i++) {
            var row = summaryEls[i].closest(".annotation-row");
            if (row) {
                var idx = parseInt(row.getAttribute("data-index"), 10);
                summaryEls[i].textContent = buildAnnotationSummary(state.annotations[idx]);
            }
        }
    }

    function handleKeypointRadiusChange(e) {
        state.keypointRadius = parseInt(e.target.value, 10);
        render();
        updateSliderTrack(e.target);
        var label = annotationList.querySelector(".keypoint-radius-value");
        if (label) label.textContent = state.keypointRadius;
    }

    function handleConnectionWidthChange(e) {
        state.connectionWidth = parseInt(e.target.value, 10);
        render();
        updateSliderTrack(e.target);
        var label = annotationList.querySelector(".connection-width-value");
        if (label) label.textContent = state.connectionWidth;
    }

    function handleMaskAlphaChange(e) {
        state.maskAlpha = parseInt(e.target.value, 10) / 100;
        render();
        updateSliderTrack(e.target);
        var label = annotationList.querySelector(".mask-alpha-value");
        if (label) label.textContent = Math.round(state.maskAlpha * 100) + '%';
    }

    function handleConnectionAlphaChange(e) {
        state.connectionAlpha = parseInt(e.target.value, 10) / 100;
        render();
        updateSliderTrack(e.target);
        var label = annotationList.querySelector(".connection-alpha-value");
        if (label) label.textContent = Math.round(state.connectionAlpha * 100) + '%';
    }

    function handleBboxLineWidthChange(e) {
        state.bboxLineWidth = parseInt(e.target.value, 10);
        render();
        updateSliderTrack(e.target);
        var label = annotationList.querySelector(".bbox-line-width-value");
        if (label) label.textContent = state.bboxLineWidth;
    }

    function handleExpandClick(e) {
        e.stopPropagation();
        var idx = parseInt(e.currentTarget.getAttribute("data-index"), 10);
        if (state.expandedIndex === idx) {
            state.expandedIndex = -1;
        } else {
            state.expandedIndex = idx;
        }
        renderControlPanel();
    }

    function handleCheckboxChange(e) {
        var idx = parseInt(e.target.getAttribute("data-index"), 10);
        state.visibility[idx] = e.target.checked;
        render();
        updateHeaderStats();
    }

    function handleRowClick(e) {
        var idx = parseInt(e.currentTarget.getAttribute("data-index"), 10);
        if (state.selectedIndex === idx) {
            state.selectedIndex = -1;
        } else {
            state.selectedIndex = idx;
        }
        render();
        renderControlPanel();
    }

    // Toggle base image
    toggleImageBtn.addEventListener("click", function () {
        state.showImage = !state.showImage;
        toggleImageBtn.classList.toggle("active", state.showImage);
        render();
    });

    // Reset to defaults
    resetBtn.addEventListener("click", resetToDefaults);

    // Maximize / minimize
    function toggleMaximize() {
        state.maximized = !state.maximized;
        container.classList.toggle("maximized", state.maximized);

        if (state.maximized) {
            document.body.style.overflow = "hidden";
        } else {
            document.body.style.overflow = "";
        }

        if (state.image) {
            requestAnimationFrame(function () {
                fitCanvas();
                resetZoom();
            });
        }
    }

    maximizeBtn.addEventListener("click", toggleMaximize);

    // Help dialog
    function toggleHelp() {
        helpOverlay.classList.toggle("visible");
    }

    helpBtn.addEventListener("click", toggleHelp);
    helpCloseBtn.addEventListener("click", toggleHelp);
    helpOverlay.addEventListener("click", function (e) {
        if (e.target === helpOverlay) toggleHelp();
    });

    // ── Canvas Mouse Interaction (Pan + Selection) ────────────────

    canvas.addEventListener("mousedown", function (e) {
        if (e.button !== 0) return;
        if (!state.image) return;

        state.isPanning = true;
        state.didDrag = false;
        state.panStartX = e.clientX;
        state.panStartY = e.clientY;
        state.panStartPanX = state.panX;
        state.panStartPanY = state.panY;

        if (state.zoom > 1) {
            canvas.style.cursor = "grabbing";
        }
    });

    window.addEventListener("mousemove", function (e) {
        if (!state.image) return;

        if (state.isPanning) {
            var dx = e.clientX - state.panStartX;
            var dy = e.clientY - state.panStartY;

            if (!state.didDrag && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
                state.didDrag = true;
            }

            if (state.didDrag && state.zoom > 1) {
                var rect = canvas.getBoundingClientRect();
                var cssToCanvasX = canvas.width / rect.width;
                var cssToCanvasY = canvas.height / rect.height;
                state.panX = state.panStartPanX + dx * cssToCanvasX;
                state.panY = state.panStartPanY + dy * cssToCanvasY;
                clampPan();
                render();
            }
            return;
        }

        // Tooltip logic (only when not dragging)
        var rect = canvas.getBoundingClientRect();
        if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return;
        if (state.annotations.length === 0) return;

        var pt = clientToCanvas(e.clientX, e.clientY);
        var cx = pt.x;
        var cy = pt.y;

        // Update cursor
        var hitIndex = findHitAnnotationIndex(cx, cy);
        if (state.zoom > 1) {
            canvas.style.cursor = hitIndex >= 0 ? "pointer" : "grab";
        } else {
            canvas.style.cursor = hitIndex >= 0 ? "pointer" : "default";
        }

        var nearest = findNearestKeypoint(cx, cy);
        var tooltipText = "";
        if (nearest) {
            tooltipText = nearest.kp.name;
            if (nearest.kp.confidence != null) {
                tooltipText += " (" + (nearest.kp.confidence * 100).toFixed(1) + "%)";
            }
        } else {
            var bboxHit = findBboxAt(cx, cy);
            if (bboxHit) {
                tooltipText = bboxHit.label || "";
                if (bboxHit.score != null) {
                    tooltipText += (tooltipText ? " " : "") + (bboxHit.score * 100).toFixed(1) + "%";
                }
            }
        }

        if (tooltipText) {
            tooltip.textContent = tooltipText;
            tooltip.classList.add("visible");

            var containerRect = container.getBoundingClientRect();
            var tooltipX = e.clientX - containerRect.left + 12;
            var tooltipY = e.clientY - containerRect.top - 8;
            tooltip.style.left = tooltipX + "px";
            tooltip.style.top = tooltipY + "px";
        } else {
            tooltip.classList.remove("visible");
        }
    });

    window.addEventListener("mouseup", function (e) {
        if (!state.isPanning) return;
        state.isPanning = false;

        if (state.zoom > 1) {
            canvas.style.cursor = "grab";
        }

        if (!state.didDrag && state.image && state.annotations.length > 0) {
            var pt = clientToCanvas(e.clientX, e.clientY);
            var hits = findAllHitAnnotationIndices(pt.x, pt.y);
            if (e.shiftKey && hits.length > 0) {
                // Shift+click: hide the topmost hit
                var target = hits[0];
                state.visibility[target] = false;
                if (state.selectedIndex === target) state.selectedIndex = -1;
            } else if (hits.length === 0) {
                state.selectedIndex = -1;
            } else if (hits.length === 1) {
                // Single hit: toggle as before
                state.selectedIndex = state.selectedIndex === hits[0] ? -1 : hits[0];
            } else {
                // Multiple overlapping hits: cycle through them
                var curPos = hits.indexOf(state.selectedIndex);
                if (curPos < 0) {
                    state.selectedIndex = hits[0];
                } else {
                    state.selectedIndex = hits[(curPos + 1) % hits.length];
                }
            }
            render();
            renderControlPanel();
        }
    });

    function findHitAnnotationIndex(cx, cy) {
        var hits = findAllHitAnnotationIndices(cx, cy);
        return hits.length > 0 ? hits[0] : -1;
    }

    function findAllHitAnnotationIndices(cx, cy) {
        var hitR = HIT_RADIUS / state.zoom;
        var hitR2 = hitR * hitR;
        var result = [];
        var keypointHitSet = {};
        // Check keypoints first (more precise)
        for (var i = 0; i < state.annotations.length; i++) {
            if (!isAnnotationVisible(i)) continue;
            var kps = state.annotations[i].keypoints || [];
            for (var j = 0; j < kps.length; j++) {
                var kp = kps[j];
                if (!isKeypointVisible(kp)) continue;
                var dx = cx - kp.x * state.scale;
                var dy = cy - kp.y * state.scale;
                if (dx * dx + dy * dy < hitR2) {
                    result.push(i);
                    keypointHitSet[i] = true;
                    break;
                }
            }
        }
        // Then check bboxes (topmost first)
        for (var i = state.annotations.length - 1; i >= 0; i--) {
            if (keypointHitSet[i]) continue;
            if (!isAnnotationVisible(i)) continue;
            var bbox = state.annotations[i].bbox;
            if (!bbox) continue;
            var bx = bbox.x * state.scale;
            var by = bbox.y * state.scale;
            if (cx >= bx && cx <= bx + bbox.width * state.scale && cy >= by && cy <= by + bbox.height * state.scale) {
                result.push(i);
            }
        }
        return result;
    }

    // ── Tooltip (mouseleave) ────────────────────────────────────

    canvas.addEventListener("mouseleave", function () {
        tooltip.classList.remove("visible");
        if (!state.isPanning) {
            canvas.style.cursor = state.zoom > 1 ? "grab" : "default";
        }
    });

    function findNearestKeypoint(cx, cy) {
        var best = null;
        var hitR = HIT_RADIUS / state.zoom;
        var bestDist = hitR * hitR;

        for (var i = 0; i < state.annotations.length; i++) {
            if (!isAnnotationVisible(i)) continue;
            var ann = state.annotations[i];
            var kps = ann.keypoints || [];
            for (var j = 0; j < kps.length; j++) {
                var kp = kps[j];
                if (!isKeypointVisible(kp)) continue;

                var kx = kp.x * state.scale;
                var ky = kp.y * state.scale;
                var dx = cx - kx;
                var dy = cy - ky;
                var dist = dx * dx + dy * dy;

                if (dist < bestDist) {
                    bestDist = dist;
                    best = { kp: kp, ann: ann };
                }
            }
        }
        return best;
    }

    function findBboxAt(cx, cy) {
        for (var i = state.annotations.length - 1; i >= 0; i--) {
            if (!isAnnotationVisible(i)) continue;
            var ann = state.annotations[i];
            var bbox = ann.bbox;
            if (!bbox) continue;

            var bx = bbox.x * state.scale;
            var by = bbox.y * state.scale;
            var bw = bbox.width * state.scale;
            var bh = bbox.height * state.scale;

            if (cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh) {
                return ann;
            }
        }
        return null;
    }

    // ── Wheel Zoom ─────────────────────────────────────────────────

    canvas.addEventListener("wheel", function (e) {
        if (!state.image) return;
        e.preventDefault();

        var delta = e.deltaY;
        if (e.deltaMode === 1) delta *= 16;
        else if (e.deltaMode === 2) delta *= 100;

        var newZoom = state.zoom * (1 - delta * ZOOM_SENSITIVITY);
        newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));

        // Zoom toward cursor position
        var rect = canvas.getBoundingClientRect();
        var mx = (e.clientX - rect.left) * (canvas.width / rect.width);
        var my = (e.clientY - rect.top) * (canvas.height / rect.height);

        state.panX = mx - (mx - state.panX) * (newZoom / state.zoom);
        state.panY = my - (my - state.panY) * (newZoom / state.zoom);
        state.zoom = newZoom;
        clampPan();
        render();
    }, { passive: false });

    // ── Double-click to Reset Zoom ──────────────────────────────

    canvas.addEventListener("dblclick", function (e) {
        if (!state.image) return;
        e.preventDefault();
        resetZoom();
        canvas.style.cursor = "default";
    });

    // ── Touch Support (Pinch-Zoom + Pan) ────────────────────────

    var touchState = { lastDist: 0, lastCenterX: 0, lastCenterY: 0, touchCount: 0 };

    function getTouchDistance(t1, t2) {
        var dx = t1.clientX - t2.clientX;
        var dy = t1.clientY - t2.clientY;
        return Math.sqrt(dx * dx + dy * dy);
    }

    function getTouchCenter(t1, t2) {
        return { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
    }

    canvas.addEventListener("touchstart", function (e) {
        if (!state.image) return;
        e.preventDefault();

        touchState.touchCount = e.touches.length;

        if (e.touches.length === 1) {
            state.isPanning = true;
            state.didDrag = false;
            state.panStartX = e.touches[0].clientX;
            state.panStartY = e.touches[0].clientY;
            state.panStartPanX = state.panX;
            state.panStartPanY = state.panY;
        } else if (e.touches.length === 2) {
            state.isPanning = false;
            touchState.lastDist = getTouchDistance(e.touches[0], e.touches[1]);
            var center = getTouchCenter(e.touches[0], e.touches[1]);
            touchState.lastCenterX = center.x;
            touchState.lastCenterY = center.y;
        }
    }, { passive: false });

    canvas.addEventListener("touchmove", function (e) {
        if (!state.image) return;
        e.preventDefault();

        if (e.touches.length === 1 && state.isPanning) {
            var dx = e.touches[0].clientX - state.panStartX;
            var dy = e.touches[0].clientY - state.panStartY;

            if (!state.didDrag && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
                state.didDrag = true;
            }

            if (state.didDrag && state.zoom > 1) {
                var rect = canvas.getBoundingClientRect();
                state.panX = state.panStartPanX + dx * (canvas.width / rect.width);
                state.panY = state.panStartPanY + dy * (canvas.height / rect.height);
                clampPan();
                render();
            }
        } else if (e.touches.length === 2) {
            var dist = getTouchDistance(e.touches[0], e.touches[1]);
            var center = getTouchCenter(e.touches[0], e.touches[1]);

            if (touchState.lastDist > 0) {
                var scale = dist / touchState.lastDist;
                var newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, state.zoom * scale));

                var rect = canvas.getBoundingClientRect();
                var mx = (center.x - rect.left) * (canvas.width / rect.width);
                var my = (center.y - rect.top) * (canvas.height / rect.height);

                state.panX = mx - (mx - state.panX) * (newZoom / state.zoom);
                state.panY = my - (my - state.panY) * (newZoom / state.zoom);

                // Simultaneous pan
                var panDx = (center.x - touchState.lastCenterX) * (canvas.width / rect.width);
                var panDy = (center.y - touchState.lastCenterY) * (canvas.height / rect.height);
                state.panX += panDx;
                state.panY += panDy;

                state.zoom = newZoom;
                clampPan();
                render();
            }

            touchState.lastDist = dist;
            touchState.lastCenterX = center.x;
            touchState.lastCenterY = center.y;
        }
    }, { passive: false });

    canvas.addEventListener("touchend", function (e) {
        if (!state.image) return;
        e.preventDefault();

        if (e.touches.length === 0) {
            if (!state.didDrag && touchState.touchCount === 1 && state.annotations.length > 0) {
                var touch = e.changedTouches[0];
                var pt = clientToCanvas(touch.clientX, touch.clientY);
                var hits = findAllHitAnnotationIndices(pt.x, pt.y);
                if (hits.length === 0) {
                    state.selectedIndex = -1;
                } else if (hits.length === 1) {
                    state.selectedIndex = state.selectedIndex === hits[0] ? -1 : hits[0];
                } else {
                    var curPos = hits.indexOf(state.selectedIndex);
                    if (curPos < 0) {
                        state.selectedIndex = hits[0];
                    } else {
                        state.selectedIndex = hits[(curPos + 1) % hits.length];
                    }
                }
                render();
                renderControlPanel();
            }
            state.isPanning = false;
            touchState.lastDist = 0;
            touchState.touchCount = 0;
        } else if (e.touches.length === 1) {
            // Transitioned from two fingers to one — restart single-finger pan
            state.isPanning = true;
            state.didDrag = false;
            state.panStartX = e.touches[0].clientX;
            state.panStartY = e.touches[0].clientY;
            state.panStartPanX = state.panX;
            state.panStartPanY = state.panY;
            touchState.lastDist = 0;
        }
    }, { passive: false });

    // ── Keyboard Shortcuts ────────────────────────────────────────

    element.setAttribute("tabindex", "0");
    element.style.outline = "none";

    element.addEventListener("keydown", function (e) {
        // Help dialog shortcuts (work even without image data)
        if (e.key === "?" && e.target.tagName !== "INPUT") {
            toggleHelp();
            e.preventDefault();
            return;
        }
        if (e.key === "Escape" && helpOverlay.classList.contains("visible")) {
            toggleHelp();
            e.preventDefault();
            return;
        }

        if (!state.image) return;

        if (e.key === "Escape") {
            if (state.maximized) {
                toggleMaximize();
                e.preventDefault();
            } else if (state.selectedIndex >= 0) {
                state.selectedIndex = -1;
                render();
                renderControlPanel();
                e.preventDefault();
            }
        } else if (e.key === "f" || e.key === "F") {
            if (e.target.tagName === "INPUT") return;
            toggleMaximize();
            e.preventDefault();
        } else if (e.key === "i" || e.key === "I") {
            if (e.target.tagName === "INPUT") return;
            state.showImage = !state.showImage;
            toggleImageBtn.classList.toggle("active", state.showImage);
            render();
            e.preventDefault();
        } else if (e.key === "a" || e.key === "A") {
            if (e.target.tagName === "INPUT") return;
            var anyVisible = false;
            for (var i = 0; i < state.visibility.length; i++) {
                if (state.visibility[i]) { anyVisible = true; break; }
            }
            var newVal = !anyVisible;
            for (var i = 0; i < state.visibility.length; i++) {
                state.visibility[i] = newVal;
            }
            render();
            renderControlPanel();
            e.preventDefault();
        } else if (e.key === "h" || e.key === "H") {
            if (e.target.tagName === "INPUT") return;
            if (state.selectedIndex >= 0) {
                var idx = state.selectedIndex;
                state.visibility[idx] = false;
                state.selectedIndex = -1;
                render();
                renderControlPanel();
            }
            e.preventDefault();
        } else if (e.key === "r" || e.key === "R") {
            if (e.target.tagName === "INPUT") return;
            resetToDefaults();
            e.preventDefault();
        } else if (e.key === "+" || e.key === "=") {
            if (e.target.tagName === "INPUT") return;
            zoomToCenter(state.zoom * 1.25);
            e.preventDefault();
        } else if (e.key === "-" || e.key === "_") {
            if (e.target.tagName === "INPUT") return;
            zoomToCenter(state.zoom / 1.25);
            e.preventDefault();
        } else if (e.key === "0") {
            if (e.target.tagName === "INPUT") return;
            resetZoom();
            canvas.style.cursor = "default";
            e.preventDefault();
        }
    });

    // ── Window Resize ─────────────────────────────────────────────

    var resizeTimer = null;
    window.addEventListener("resize", function () {
        if (resizeTimer) clearTimeout(resizeTimer);
        resizeTimer = setTimeout(function () {
            if (state.image) {
                fitCanvas();
                if (!state.maximized) resetZoom();
                else render();
            }
        }, 150);
    });
})();

class DetectionViewer(gr.HTML):
    def __init__(
        self,
        value: tuple[str | Path | Image.Image | np.ndarray, list[dict[str, Any]]]
        | tuple[str | Path | Image.Image | np.ndarray, list[dict[str, Any]], dict[str, Any]]
        | None = None,
        *,
        label: str | None = None,
        panel_title: str = "Detections",
        list_height: int = 300,
        score_threshold: tuple[float, float] = (0.0, 1.0),
        keypoint_threshold: float = 0.0,
        keypoint_radius: int = 3,
        **kwargs: object,
    ) -> None:
        html_template = (_STATIC_DIR / "template.html").read_text(encoding="utf-8")
        html_template = html_template.replace("${panel_title}", panel_title)
        html_template = html_template.replace("${list_height}", str(list_height))
        html_template = html_template.replace("${score_threshold_min}", str(score_threshold[0]))
        html_template = html_template.replace("${score_threshold_max}", str(score_threshold[1]))
        html_template = html_template.replace("${keypoint_threshold}", str(keypoint_threshold))
        html_template = html_template.replace("${keypoint_radius}", str(keypoint_radius))
        css_template = (_STATIC_DIR / "style.css").read_text(encoding="utf-8")
        css_template = css_template.replace("${list_height}", str(list_height))
        js_on_load = (_STATIC_DIR / "script.js").read_text(encoding="utf-8")

        has_label = label is not None
        super().__init__(
            value=value,
            label=label,
            show_label=has_label,
            container=has_label,
            html_template=html_template,
            css_template=css_template,
            js_on_load=js_on_load,
            **kwargs,
        )

    def postprocess(self, value: Any) -> str | None:  # noqa: ANN401
        if isinstance(value, str):
            return value
        return self._process(value)

    def _process(
        self,
        value: tuple[str | Path | Image.Image | np.ndarray, list[dict[str, Any]]]
        | tuple[str | Path | Image.Image | np.ndarray, list[dict[str, Any]], dict[str, Any]]
        | None,
    ) -> str | None:
        if value is None:
            return None

        if len(value) == 3:  # noqa: PLR2004 - tuple length check
            image_src, annotations, config = value
        else:
            image_src, annotations = value
            config = {}

        img = _load_image(image_src)
        image_url = _save_image_to_cache(img, self.GRADIO_CACHE)

        processed: list[dict[str, Any]] = []
        for i, ann in enumerate(annotations):
            has_kps = "keypoints" in ann and len(ann["keypoints"]) > 0
            default_label = f"Person {i + 1}" if has_kps else f"Detection {i + 1}"

            entry: dict[str, Any] = {
                "keypoints": ann.get("keypoints", []),
                "connections": ann.get("connections", []),
                "color": ann.get("color", _COLOR_PALETTE[i % len(_COLOR_PALETTE)]),
                "label": ann.get("label", default_label),
            }
            if "bbox" in ann:
                entry["bbox"] = ann["bbox"]
            if "score" in ann:
                entry["score"] = ann["score"]
            if "mask" in ann:
                entry["mask"] = ann["mask"]
            processed.append(entry)

        result: dict[str, Any] = {"image": image_url, "annotations": processed}
        if "score_threshold" in config:
            result["scoreThresholdMin"] = config["score_threshold"][0]
            result["scoreThresholdMax"] = config["score_threshold"][1]

        return json.dumps(result)

    def api_info(self) -> dict[str, Any]:
        return {
            "type": "string",
            "description": (
                "JSON string containing detection visualization data. "
                "Structure: {image: string (URL), annotations: ["
                "{color: string, label: string, "
                "bbox?: {x: float, y: float, width: float, height: float}, "
                "score?: float, "
                "mask?: {counts: [int], size: [int, int]}, "
                "keypoints?: [{x: float, y: float, name: string, confidence?: float}], "
                "connections?: [[int, int]]}], "
                "scoreThresholdMin?: float, scoreThresholdMax?: float}"
            ),
        }


Contribution Heatmap

${(() => {
            const total = Object.values(value || {}).reduce((a, b) => a + b, 0);
            return '📊 ' + total.toLocaleString() + ' contributions in ' + year;
        })()}

            Less

            More

        ${(() => {
            const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
            return months.map((m, i) =>
                '' + m + ''
            ).join('');
        })()}

            MonWedFri

            ${(() => {
                const v = value || {};
                const sd = new Date(year, 0, 1);
                const pad = sd.getDay();
                const cells = [];
                for (let i = 0; i < pad; i++) cells.push('

');
                const totalDays = Math.floor((new Date(year, 11, 31) - sd) / 86400000) + 1;
                for (let d = 0; d < totalDays; d++) {
                    const dt = new Date(year, 0, 1 + d);
                    const key = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0');
                    const count = v[key] || 0;
                    let lv = 0;
                    if (count > 0) lv = 1;
                    if (count >= 3) lv = 2;
                    if (count >= 6) lv = 3;
                    if (count >= 10) lv = 4;
                    const mn = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
                    const tip = count + ' contributions on ' + mn[dt.getMonth()] + ' ' + dt.getDate() + ', ' + year;
                    cells.push('

');
                }
                return cells.join('');
            })()}

        ${(() => {
            const v = value || {};
            const totalDays = Math.floor((new Date(year, 11, 31) - new Date(year, 0, 1)) / 86400000) + 1;
            let streak = 0, maxStreak = 0, total = 0, active = 0, best = 0;
            const vals = [];
            for (let d = 0; d < totalDays; d++) {
                const dt = new Date(year, 0, 1 + d);
                const key = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0');
                const c = v[key] || 0;
                total += c;
                if (c > 0) { streak++; maxStreak = Math.max(maxStreak, streak); active++; best = Math.max(best, c); vals.push(c); }
                else { streak = 0; }
            }
            const avg = vals.length ? (total / vals.length).toFixed(1) : '0';
            const stats = [
                ['🔥', maxStreak, 'Longest Streak'],
                ['📅', active, 'Active Days'],
                ['⚡', best, 'Best Day'],
                ['📈', avg, 'Avg / Active Day'],
                ['🏆', total.toLocaleString(), 'Total'],
            ];
            return stats.map(s =>
                '
' + s[1] + '' + s[0] + ' ' + s[2] + '
'
            ).join('');
        })()}

    .heatmap-container {
        background: #0d1117;
        border-radius: 12px;
        padding: 24px;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
        color: #c9d1d9;
        overflow-x: auto;
    }
    .heatmap-header {
        display: flex; justify-content: space-between; align-items: center;
        margin-bottom: 12px; flex-wrap: wrap; gap: 10px;
    }
    .heatmap-header h2 { margin: 0; font-size: 16px; font-weight: 500; color: #f0f6fc; }
    .legend { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #8b949e; }
    .legend-box { width: 12px; height: 12px; border-radius: 2px; }
    .month-labels {
        display: grid; grid-template-columns: 30px repeat(53, 1fr);
        font-size: 11px; color: #8b949e; margin-bottom: 4px; padding-left: 2px;
    }
    .heatmap-grid { display: flex; gap: 4px; }
    .day-labels {
        display: grid; grid-template-rows: repeat(7, 1fr);
        font-size: 11px; color: #8b949e; width: 30px; gap: 2px;
    }
    .day-labels span { height: 13px; display: flex; align-items: center; }
    .cells {
        display: grid; grid-template-rows: repeat(7, 1fr);
        grid-auto-flow: column; gap: 2px; flex: 1;
    }
    .cell {
        width: 13px; height: 13px; border-radius: 2px; cursor: pointer;
        transition: all 0.15s ease; outline: 1px solid rgba(27,31,35,0.06);
    }
    .cell:hover {
        outline: 2px solid #58a6ff; outline-offset: -1px;
        transform: scale(1.3); z-index: 1;
    }
    .cell.empty { visibility: hidden; }
    .level-0 { background: ${c0}; }
    .level-1 { background: ${c1}; }
    .level-2 { background: ${c2}; }
    .level-3 { background: ${c3}; }
    .level-4 { background: ${c4}; }
    .stats-bar {
        display: flex; justify-content: space-around; margin-top: 20px;
        padding-top: 16px; border-top: 1px solid #21262d;
        flex-wrap: wrap; gap: 12px;
    }
    .stat { display: flex; flex-direction: column; align-items: center; gap: 4px; }
    .stat-value { font-size: 22px; font-weight: 700; color: ${c4}; }
    .stat-label { font-size: 12px; color: #8b949e; }

    element.addEventListener('click', (e) => {
        if (e.target && e.target.classList.contains('cell') && !e.target.classList.contains('empty')) {
            const date = e.target.dataset.date;
            const cur = parseInt(e.target.dataset.count) || 0;
            const next = cur >= 12 ? 0 : cur + 1;
            const nv = {...(props.value || {})};
            if (next === 0) delete nv[date]; else nv[date] = next;
            props.value = nv;
            trigger('change');
        }
    });

class ContributionHeatmap(gr.HTML):
    """Reusable GitHub-style contribution heatmap built on gr.HTML."""

    def __init__(self, value=None, year=2025, theme="green",
                 c0=None, c1=None, c2=None, c3=None, c4=None, **kwargs):
        if value is None:
            value = {}
        # Use explicit c0-c4 if provided (from gr.HTML updates), else derive from theme
        colors = COLOR_SCHEMES.get(theme, COLOR_SCHEMES["green"])
        super().__init__(
            value=value,
            year=year,
            c0=c0 or colors[0],
            c1=c1 or colors[1],
            c2=c2 or colors[2],
            c3=c3 or colors[3],
            c4=c4 or colors[4],
            html_template=HEATMAP_HTML,
            css_template=HEATMAP_CSS,
            js_on_load=HEATMAP_JS,
            **kwargs,
        )

    def api_info(self):
        return {"type": "object", "description": "Dict mapping YYYY-MM-DD to int counts"}