<style nonce="{{ csp_nonce }}">
/* Visual UI Styles for Plan Analysis Results */
/* Bulk Actions Toolbar */
.bulk-actions-toolbar {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.action-btn {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 16px;
border-radius: 8px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.action-btn:hover {
background: var(--border);
border-color: var(--accent);
color: var(--text);
}
/* Thumbnail Gallery */
.thumbnail-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
@media (max-width: 640px) {
.thumbnail-gallery {
grid-template-columns: repeat(2, 1fr);
}
}
.thumbnail-item {
position: relative;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
transition: all 0.2s;
}
.thumbnail-item:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.thumbnail-img {
width: 100%;
aspect-ratio: 34 / 22;
object-fit: cover;
display: block;
}
.thumbnail-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
padding: 8px;
color: var(--text);
}
.page-num {
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
}
.sheet-id {
font-size: 0.7rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Detail Card Panel */
.detail-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px;
margin: 24px 0;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.detail-header h3 {
font-size: 1.2rem;
color: var(--text);
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 2rem;
cursor: pointer;
line-height: 1;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-btn:hover {
color: var(--error);
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
.detail-image img {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
}
.metadata-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.metadata-table th,
.metadata-table td {
text-align: left;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.metadata-table th {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 600;
width: 40%;
}
.metadata-table td {
color: var(--text);
font-size: 0.9rem;
}
.detail-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Lightbox Viewer */
.lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
}
.lightbox-viewport {
overflow: hidden;
max-width: 90vw;
max-height: 80vh;
cursor: grab;
border-radius: 8px;
position: relative;
touch-action: none;
}
.lightbox-viewport.dragging {
cursor: grabbing;
}
.lightbox-viewport .annotated-image {
transform-origin: 0 0;
transition: none;
will-change: transform;
overflow: visible;
}
.lightbox-content img {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
display: block;
user-select: none;
-webkit-user-drag: none;
}
.zoom-controls {
position: absolute;
bottom: 80px;
right: 20px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 1010;
}
.zoom-btn {
width: 40px;
height: 40px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.7);
color: var(--text);
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.zoom-btn:hover {
background: rgba(79, 143, 247, 0.3);
border-color: var(--accent);
}
.zoom-level {
text-align: center;
font-size: 0.7rem;
color: var(--text-muted);
user-select: none;
}
.lightbox-close {
position: absolute;
top: -50px;
right: 0;
background: none;
border: none;
color: var(--text);
font-size: 3rem;
cursor: pointer;
line-height: 1;
padding: 0;
width: 40px;
height: 40px;
transition: color 0.2s;
}
.lightbox-close:hover {
color: var(--error);
}
.lightbox-prev,
.lightbox-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
border: 1px solid var(--border);
color: var(--text);
font-size: 3rem;
width: 60px;
height: 60px;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
z-index: 1025;
}
.lightbox-prev {
left: -75px;
}
.lightbox-next {
right: -75px;
}
/* On narrow screens, pull arrows back inside content edges */
@media (max-width: 1100px) {
.lightbox-prev { left: 8px; }
.lightbox-next { right: 8px; }
}
.lightbox-prev:hover,
.lightbox-next:hover {
background: rgba(79, 143, 247, 0.3);
border-color: var(--accent);
}
.lightbox-info {
margin-top: 16px;
display: flex;
align-items: center;
gap: 20px;
color: var(--text);
}
.lightbox-actions {
display: flex;
gap: 10px;
}
.lightbox-actions button {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 16px;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.lightbox-actions button:hover {
background: var(--border);
border-color: var(--accent);
}
/* Comparison Panel */
.comparison-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px;
margin: 24px 0;
}
.comparison-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.comparison-header h3 {
font-size: 1.2rem;
color: var(--text);
margin: 0;
}
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 20px;
}
@media (max-width: 768px) {
.comparison-grid {
grid-template-columns: 1fr;
}
}
.comparison-side label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.comparison-side select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 10px 12px;
font-size: 0.9rem;
font-family: inherit;
margin-bottom: 12px;
}
.comparison-side select:focus {
outline: none;
border-color: var(--accent);
}
.comparison-side img {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
}
.comparison-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.comparison-actions button {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 16px;
border-radius: 8px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.comparison-actions button:hover {
background: var(--border);
border-color: var(--accent);
}
/* Email Modal */
.email-modal {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.8);
}
.modal-content {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px;
max-width: 500px;
width: 90%;
z-index: 1;
}
.modal-content h3 {
font-size: 1.3rem;
color: var(--text);
margin: 0 0 20px;
}
.modal-content label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.modal-content input[type="email"],
.modal-content textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 12px 16px;
font-size: 0.95rem;
font-family: inherit;
margin-bottom: 16px;
}
.modal-content input[type="email"]:focus,
.modal-content textarea:focus {
outline: none;
border-color: var(--accent);
}
.modal-content textarea {
resize: vertical;
min-height: 100px;
}
.modal-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.modal-actions .btn {
flex: 1;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.modal-actions .btn:hover {
background: var(--accent-hover);
}
.modal-actions .btn-secondary {
flex: 1;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: 12px 24px;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
}
.modal-actions .btn-secondary:hover {
background: var(--border);
}
/* Markdown Report */
.markdown-report {
margin-top: 32px;
}
/* Report status filter chips */
.report-filter-bar {
display: flex;
gap: 8px;
margin-top: 32px;
margin-bottom: -16px;
flex-wrap: wrap;
align-items: center;
}
.report-filter-bar .filter-label {
font-size: 0.8rem;
color: var(--text-muted);
margin-right: 4px;
}
.report-filter-bar .filter-chip {
padding: 4px 14px;
border: 1px solid var(--border);
border-radius: 16px;
font-size: 0.8rem;
cursor: pointer;
background: none;
color: var(--text-muted);
font-family: inherit;
transition: all 0.15s;
}
.report-filter-bar .filter-chip:hover {
border-color: var(--accent);
color: var(--text);
}
.report-filter-bar .filter-chip.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.report-filter-bar .filter-chip .chip-count {
font-size: 0.7rem;
opacity: 0.7;
margin-left: 4px;
}
/* Status-specific chip colors when active */
.report-filter-bar .filter-chip.active[data-value="fail"] { background: #ef4444; border-color: #ef4444; }
.report-filter-bar .filter-chip.active[data-value="warn"] { background: #f59e0b; border-color: #f59e0b; }
.report-filter-bar .filter-chip.active[data-value="pass"] { background: #22c55e; border-color: #22c55e; }
.report-filter-bar .filter-chip.active[data-value="skip"] { background: #6b7280; border-color: #6b7280; }
.report-filter-bar .filter-chip.active[data-value="info"] { background: #3b82f6; border-color: #3b82f6; }
/* Status badge pills on EPR h3 headings — color-matches the filter chips */
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
color: #fff;
margin-right: 6px;
vertical-align: middle;
letter-spacing: 0.02em;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-muted);
background: var(--surface-2);
border-radius: 8px;
margin-bottom: 24px;
}
.validate-header {
margin-bottom: 20px;
}
.validate-header h2 {
font-size: 1.3rem;
margin: 0 0 8px;
color: var(--accent);
}
.validate-meta {
color: var(--text-muted);
font-size: 0.9rem;
}
.validate-meta .file-hint {
font-size: 0.75rem;
opacity: 0.7;
}
/* ========== Annotation Overlay ========== */
.annotated-image {
position: relative;
display: inline-block;
width: 100%;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2;
}
.annotation-layer .annotation {
pointer-events: auto;
cursor: default;
}
.annotation-layer .annotation:hover .ann-label-bg {
opacity: 1 !important;
}
.annotation-layer .annotation:hover .ann-label-text {
font-weight: 700;
}
/* Thumbnail annotations: dots only */
.annotation-thumbnail .ann-label-bg,
.annotation-thumbnail .ann-label-text,
.annotation-thumbnail .ann-leader {
display: none;
}
/* Annotation toggle/filter toolbar */
.annotation-controls {
display: flex;
gap: 8px;
align-items: center;
position: relative;
}
/* Annotation legend dropdown */
.annotation-legend {
position: absolute;
top: 100%;
right: 0;
margin-top: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
z-index: 10;
min-width: 220px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.82rem;
color: var(--text);
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.2);
}
.annotation-controls select {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
}
.annotation-controls select:focus {
outline: none;
border-color: var(--accent);
}
.ann-toggle-on {
background: rgba(34, 197, 94, 0.15) !important;
border-color: #22c55e !important;
color: #22c55e !important;
}
/* Lasso selection rectangle */
.lasso-rect {
position: absolute;
border: 2px dashed var(--accent, #4f8ff7);
background: rgba(79, 143, 247, 0.12);
pointer-events: none;
z-index: 1020;
display: none;
}
.lasso-active {
cursor: crosshair !important;
}
.lasso-active * {
cursor: crosshair !important;
}
.zoom-btn.lasso-mode-on {
background: rgba(79, 143, 247, 0.4) !important;
border-color: var(--accent) !important;
color: #fff !important;
}
/* Left-side annotation legend panel (lightbox) */
.lightbox-legend-panel {
position: absolute;
top: 0;
left: 0;
width: 220px;
height: 100%;
background: rgba(0, 0, 0, 0.85);
border-right: 1px solid var(--border, #333749);
z-index: 1015;
overflow-y: auto;
padding: 16px 14px;
transform: translateX(-100%);
transition: transform 0.25s ease;
scrollbar-width: thin;
scrollbar-color: var(--border, #333749) transparent;
}
.lightbox-legend-panel.open {
transform: translateX(0);
}
.lightbox-legend-panel h4 {
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #8b8fa3);
margin: 0 0 12px;
font-weight: 600;
}
.legend-toggle-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
user-select: none;
}
.legend-toggle-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.legend-toggle-item.disabled {
opacity: 0.35;
}
.legend-toggle-check {
width: 18px;
height: 18px;
border-radius: 4px;
border: 2px solid var(--border, #333749);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
flex-shrink: 0;
transition: all 0.15s;
}
.legend-toggle-item:not(.disabled) .legend-toggle-check {
border-color: currentColor;
}
.legend-toggle-swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-toggle-label {
font-size: 0.82rem;
color: var(--text, #e8e9ec);
flex: 1;
}
.legend-toggle-count {
font-size: 0.72rem;
color: var(--text-muted, #8b8fa3);
min-width: 16px;
text-align: right;
}
.legend-panel-divider {
border: none;
border-top: 1px solid var(--border, #333749);
margin: 10px 0;
}
.legend-panel-actions {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.legend-panel-actions button {
flex: 1;
background: none;
border: 1px solid var(--border, #333749);
color: var(--text-muted, #8b8fa3);
padding: 5px 8px;
border-radius: 5px;
font-size: 0.72rem;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.legend-panel-actions button:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--accent, #4f8ff7);
color: var(--text, #e8e9ec);
}
/* Toggle button for legend panel in lightbox */
.legend-panel-toggle {
position: absolute;
top: 12px;
left: 12px;
z-index: 1020;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--border, #333749);
background: rgba(0, 0, 0, 0.7);
color: var(--text, #e8e9ec);
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.legend-panel-toggle:hover {
background: rgba(79, 143, 247, 0.3);
border-color: var(--accent, #4f8ff7);
}
.legend-panel-toggle.active {
background: rgba(79, 143, 247, 0.4);
border-color: var(--accent, #4f8ff7);
left: 232px;
}
/* Minimap */
.minimap {
position: absolute;
bottom: 80px;
left: 20px;
width: 140px;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
z-index: 1010;
background: rgba(0, 0, 0, 0.7);
opacity: 0;
transition: opacity 0.2s;
pointer-events: auto;
}
.minimap.visible {
opacity: 1;
}
.minimap img {
width: 100%;
display: block;
max-height: none !important;
border-radius: 0 !important;
}
.minimap-viewport {
position: absolute;
border: 2px solid var(--accent, #4f8ff7);
background: rgba(79, 143, 247, 0.15);
pointer-events: none;
}
/* Compliance Upsell Teaser */
.compliance-upsell {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
margin-bottom: 20px;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.12), rgba(79, 143, 247, 0.10));
border: 1px solid rgba(124, 58, 237, 0.35);
border-radius: 10px;
}
.upsell-icon {
font-size: 1.6rem;
flex-shrink: 0;
}
.upsell-content {
flex: 1;
font-size: 0.9rem;
color: var(--text);
line-height: 1.5;
}
.upsell-cta {
flex-shrink: 0;
padding: 8px 20px;
background: var(--accent);
color: white;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
font-size: 0.85rem;
white-space: nowrap;
transition: background 0.2s;
}
.upsell-cta:hover {
background: var(--accent-hover);
}
@media (max-width: 640px) {
.compliance-upsell {
flex-direction: column;
text-align: center;
}
}
/* ========== Print Stylesheet ========== */
@media print {
/* Hide everything on the parent page when printing report */
body.printing-report .top-bar,
body.printing-report .main-nav,
body.printing-report nav,
body.printing-report header,
body.printing-report footer,
body.printing-report .hero,
body.printing-report .search-section,
body.printing-report .preset-chips,
body.printing-report .tool-cards,
body.printing-report .tabs,
body.printing-report form,
body.printing-report .feedback-section,
body.printing-report .results-section > *:not(#analyze-plans-results),
body.printing-report .section:not(:has(#analyze-plans-results)) {
display: none !important;
}
/* Hide interactive elements within the results */
body.printing-report .compliance-upsell,
body.printing-report .bulk-actions-toolbar,
body.printing-report .thumbnail-gallery,
body.printing-report .detail-card,
body.printing-report .lightbox,
body.printing-report .comparison-panel,
body.printing-report .email-modal,
body.printing-report .report-filter-bar,
body.printing-report #analyze-plans-loading {
display: none !important;
}
/* Show the report header and markdown content cleanly */
body.printing-report .result-card {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
}
body.printing-report .validate-header {
margin-bottom: 16px;
}
body.printing-report .validate-header h2 {
color: #000 !important;
font-size: 1.5rem;
}
body.printing-report .markdown-report {
color: #000 !important;
font-size: 12pt;
line-height: 1.5;
}
body.printing-report .markdown-report h1,
body.printing-report .markdown-report h2,
body.printing-report .markdown-report h3 {
color: #000 !important;
page-break-after: avoid;
}
body.printing-report .markdown-report table {
border-collapse: collapse;
width: 100%;
page-break-inside: avoid;
}
body.printing-report .markdown-report th,
body.printing-report .markdown-report td {
border: 1px solid #ccc;
padding: 6px 10px;
text-align: left;
}
body.printing-report .markdown-report pre,
body.printing-report .markdown-report code {
background: #f5f5f5 !important;
color: #000 !important;
}
/* Status badges — preserve colors for print */
body.printing-report .status-badge {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
/* Clean page setup */
@page {
margin: 0.75in;
}
}
</style>
<div class="result-card glass-card">
<div class="validate-header">
<h2>Plan Set Analysis Report</h2>
<div class="validate-meta">
<span>{{ filename }}</span>
<span class="file-hint">— {{ filesize_mb }} MB</span>
{% if quick_check %}
<span style="display: inline-block; margin-left: 12px; background: var(--surface-2); border: 1px solid var(--border); padding: 2px 10px; border-radius: 12px; font-size: 0.8rem;">Quick Check (metadata only)</span>
{% elif analysis_mode is defined and analysis_mode == 'compliance' %}
<span style="display: inline-block; margin-left: 12px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.4); color: #a78bfa; padding: 2px 10px; border-radius: 12px; font-size: 0.8rem;">Compliance Check</span>
{% endif %}
{% if annotation_count is defined and annotation_count > 0 %}
<span class="file-hint">· {{ annotation_count }} annotation{{ 's' if annotation_count != 1 }}</span>
{% endif %}
</div>
{% if vision_stats is defined and vision_stats %}
<div style="font-size: 0.75rem; color: var(--text-muted, #888); margin-top: 4px;">
AI Vision: {{ vision_stats.total_calls }} calls
· {{ '{:,}'.format(vision_stats.total_tokens) }} tokens
· ~${{ '%.2f'|format(vision_stats.estimated_cost_usd) }}
· {{ (vision_stats.total_duration_ms / 1000)|round(0)|int }}s
{% if gallery_duration_ms is defined and gallery_duration_ms %}
· Gallery: {{ (gallery_duration_ms / 1000)|round(1) }}s
{% endif %}
</div>
{% endif %}
</div>
{# ===== Previous Analyses Banner (Revision Tracking) ===== #}
{% if previous_analyses is defined and previous_analyses %}
<div class="glass-card" style="margin-bottom:var(--space-4);">
<div style="font-size:0.82rem;font-weight:600;color:var(--accent);margin-bottom:6px;">
📋 Previous {{ 'version' if previous_analyses|length == 1 else 'versions' }} analyzed
</div>
{% for prev in previous_analyses %}
<div style="display:flex;align-items:center;gap:10px;{% if not loop.first %}margin-top:6px;{% endif %}font-size:0.8rem;">
<a href="/plan-jobs/{{ prev.job_id }}/results" style="color:var(--accent);text-decoration:none;">
{{ prev.filename }}
</a>
<span style="color:var(--text-muted);">
{{ prev.completed_at.strftime('%b %d, %Y') if prev.completed_at and prev.completed_at.strftime else prev.completed_at }}
{% if prev.analysis_mode %} · {{ prev.analysis_mode }}{% endif %}
{% if prev.pages_analyzed %} · {{ prev.pages_analyzed }} pages{% endif %}
</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if session_id %}
<!-- Bulk Actions Toolbar -->
<div class="bulk-actions-toolbar">
<button onclick="downloadAllPages()" class="action-btn obsidian-btn obsidian-btn-outline">
📦 Download All Pages (ZIP)
</button>
<button onclick="printReport()" class="action-btn obsidian-btn obsidian-btn-outline">
📄 Download Analysis Report (PDF)
</button>
<button onclick="emailAnalysis()" class="action-btn obsidian-btn obsidian-btn-outline">
✉️ Email Full Analysis
</button>
<button onclick="printReport()" class="action-btn obsidian-btn obsidian-btn-outline">
🖨️ Print Report
</button>
<div class="annotation-controls">
<button id="toggle-annotations" onclick="toggleAnnotations()" class="action-btn obsidian-btn obsidian-btn-outline ann-toggle-on" aria-label="Toggle annotations">
Annotations: ON
</button>
<select id="annotation-filter" onchange="filterAnnotations()" class="obsidian-input" aria-label="Filter annotation types">
<option value="all">All Types</option>
<option value="epr_issue">EPR Issues</option>
<option value="code_reference">Code References</option>
<option value="dimension">Dimensions</option>
<option value="occupancy_label">Occupancy</option>
<option value="construction_type">Construction Type</option>
<option value="scope_indicator">Scope</option>
<option value="title_block">Title Block</option>
<option value="stamp">Stamps</option>
<option value="structural_element">Structural</option>
<option value="general_note">General Note</option>
<option value="reviewer_note">Reviewer Notes</option>
<option value="ai_reviewer_response">AI Responses</option>
</select>
<span id="filter-page-count" style="display:none; font-size:var(--text-xs); color:var(--text-secondary); margin-left:var(--space-2);"></span>
<button onclick="toggleLegend()" class="action-btn obsidian-btn obsidian-btn-outline" id="legend-btn" aria-label="Show annotation legend">
Legend
</button>
<div id="annotation-legend" class="annotation-legend" style="display:none;"></div>
</div>
</div>
<!-- Compliance Mode Upsell Teaser -->
{% if analysis_mode is defined and analysis_mode == 'compliance' %}
<div class="compliance-upsell">
<div class="upsell-icon">🔍</div>
<div class="upsell-content">
<strong>Compliance Preview</strong> —
{% if annotation_count is defined and annotation_count > 0 %}
We found <span style="color:var(--success);font-weight:700;">{{ annotation_count }} annotation{{ 's' if annotation_count != 1 }}</span> on just 1 page.
{% else %}
AI annotations are available for your plan set.
{% endif %}
Upload again and choose <strong>Full Analysis</strong> to get detailed markups on every sampled sheet — including EPR issues, code references, dimensions, and scope indicators.
</div>
<a href="/account/analyses#upload" class="upsell-cta">Upload for Full Analysis →</a>
</div>
{% endif %}
<!-- Thumbnail Gallery -->
{% if session_id and page_count > 0 %}
<div class="thumbnail-gallery" id="thumbnail-gallery">
{% for page_num in range(page_count) %}
<div class="thumbnail-item" data-page="{{ page_num }}" onclick="openPageDetail({{ page_num }})">
<div class="annotated-image">
<img src="/plan-images/{{ session_id }}/{{ page_num }}"
loading="lazy"
alt="Page {{ page_num + 1 }}"
class="thumbnail-img"
onload="renderThumbnailAnnotations(this, {{ page_num }})">
<svg class="annotation-layer annotation-thumbnail" data-page="{{ page_num }}" role="img" aria-label="Annotations for page {{ page_num + 1 }}"></svg>
</div>
<div class="thumbnail-overlay">
<div class="page-num">Page {{ page_num + 1 }}</div>
</div>
</div>
{% endfor %}
</div>
{% elif not session_id %}
<div class="empty-state">
No page images available
</div>
{% endif %}
<!-- Detail Card Panel (hidden by default) -->
<div id="detail-card" class="detail-card" style="display:none;">
<div class="detail-header">
<h3>Page <span id="detail-page-num"></span> Details</h3>
<button onclick="closeDetail()" class="close-btn">×</button>
</div>
<div class="detail-grid">
<div class="detail-image annotated-image">
<img id="detail-img" src="" alt="Page detail"
onload="renderAnnotations(this, document.getElementById('detail-annotations'), currentPage)">
<svg id="detail-annotations" class="annotation-layer" role="img" aria-label="Detail view annotations"></svg>
</div>
<div class="detail-metadata">
<table class="metadata-table">
<tr><th>Sheet #</th><td id="meta-sheet"></td></tr>
<tr><th>Address</th><td id="meta-address"></td></tr>
<tr><th>Firm</th><td id="meta-firm"></td></tr>
<tr><th>Professional Stamp</th><td id="meta-stamp"></td></tr>
</table>
<div class="detail-actions">
<button onclick="downloadPage()" class="action-btn obsidian-btn obsidian-btn-outline">Download Page</button>
<button onclick="openLightbox(currentPage)" class="action-btn obsidian-btn obsidian-btn-outline">Full Screen</button>
<button onclick="openComparison()" class="action-btn obsidian-btn obsidian-btn-outline">Compare</button>
</div>
</div>
</div>
</div>
<!-- Lightbox Viewer (hidden by default) -->
<div id="lightbox" class="lightbox" style="display:none;" onclick="closeLightbox()">
<!-- Left-side annotation legend panel -->
<div id="lightbox-legend-panel" class="lightbox-legend-panel" onclick="event.stopPropagation()">
<h4>Annotations</h4>
<div class="legend-panel-actions">
<button onclick="legendShowAll()">Show All</button>
<button onclick="legendHideAll()">Hide All</button>
</div>
<div id="legend-panel-items"></div>
</div>
<button id="legend-panel-toggle-btn" class="legend-panel-toggle" onclick="event.stopPropagation(); toggleLegendPanel()" title="Toggle annotation legend">☰</button>
<div class="lightbox-content" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()">×</button>
<button class="lightbox-prev" onclick="event.stopPropagation(); prevPage()">‹</button>
<div id="lightbox-viewport" class="lightbox-viewport">
<div class="annotated-image" id="lightbox-zoomable" style="max-width:100%;max-height:80vh;">
<img id="lightbox-img" src="" alt="Full screen view"
onload="renderAnnotations(this, document.getElementById('lightbox-annotations'), currentPage); resetZoom();">
<svg id="lightbox-annotations" class="annotation-layer" role="img" aria-label="Lightbox annotations"></svg>
</div>
</div>
<button class="lightbox-next" onclick="event.stopPropagation(); nextPage()">›</button>
<!-- Lasso selection rectangle (inside lightbox-content so it overlays viewport) -->
<div id="lasso-rect" class="lasso-rect"></div>
<!-- Minimap (visible when zoomed in) -->
<div id="minimap" class="minimap" onclick="event.stopPropagation()">
<img id="minimap-img" src="" alt="minimap">
<div id="minimap-viewport" class="minimap-viewport"></div>
</div>
<div class="zoom-controls" onclick="event.stopPropagation()">
<button class="zoom-btn" id="lasso-btn" onclick="toggleLassoMode()" title="Lasso zoom — draw a rectangle to zoom in">⬚</button>
<button class="zoom-btn" onclick="zoomIn()" title="Zoom in">+</button>
<div class="zoom-level" id="zoom-level">100%</div>
<button class="zoom-btn" onclick="zoomOut()" title="Zoom out">−</button>
<button class="zoom-btn" onclick="resetZoom()" title="Fit to screen" style="font-size:0.8rem;">↺</button>
</div>
<div class="lightbox-info">
<span id="lightbox-page-info"></span>
<div class="lightbox-actions">
<button onclick="downloadPage()" class="obsidian-btn obsidian-btn-outline">Download</button>
<button onclick="printPage()" class="obsidian-btn obsidian-btn-outline">Print</button>
</div>
</div>
</div>
</div>
<!-- Side-by-Side Comparison (hidden by default) -->
<div id="comparison-panel" class="comparison-panel" style="display:none;">
<div class="comparison-header">
<h3>Compare Pages</h3>
<button onclick="closeComparison()" class="close-btn">×</button>
</div>
<div class="comparison-grid">
<div class="comparison-side">
<label>Left Page</label>
<select id="compare-left" onchange="updateComparisonPanel('left')">
<!-- Populated by JS -->
</select>
<div class="annotated-image">
<img id="compare-left-img" src="" alt="Left comparison"
onload="renderComparisonAnnotations('left')">
<svg id="compare-left-annotations" class="annotation-layer" role="img" aria-label="Left comparison annotations"></svg>
</div>
</div>
<div class="comparison-side">
<label>Right Page</label>
<select id="compare-right" onchange="updateComparisonPanel('right')">
<!-- Populated by JS -->
</select>
<div class="annotated-image">
<img id="compare-right-img" src="" alt="Right comparison"
onload="renderComparisonAnnotations('right')">
<svg id="compare-right-annotations" class="annotation-layer" role="img" aria-label="Right comparison annotations"></svg>
</div>
</div>
</div>
<div class="comparison-actions">
<button onclick="downloadComparison()" class="obsidian-btn obsidian-btn-outline">Download Both</button>
<button onclick="emailComparison()" class="obsidian-btn obsidian-btn-outline">Email Comparison</button>
</div>
</div>
<!-- Email Modal (hidden by default) -->
<div id="email-modal" class="email-modal" style="display:none;">
<div class="modal-backdrop" onclick="closeEmailModal()"></div>
<div class="modal-content">
<h3>Email Analysis</h3>
<form id="email-form" onsubmit="sendEmail(event)">
<label>Recipient Email</label>
<input type="email" id="email-recipient" class="obsidian-input" required>
<label>Message (optional)</label>
<textarea id="email-message" class="obsidian-input" rows="4"></textarea>
<input type="hidden" id="email-context">
<div class="modal-actions">
<button type="submit" class="btn obsidian-btn obsidian-btn-primary">Send</button>
<button type="button" onclick="closeEmailModal()" class="btn-secondary obsidian-btn obsidian-btn-outline">Cancel</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Report Status Filters -->
<div class="report-filter-bar" id="report-filter-bar" style="display:none;">
<span class="filter-label">Filter:</span>
<button class="filter-chip active" data-value="all" onclick="toggleReportFilter(this)">
All <span class="chip-count"></span>
</button>
<button class="filter-chip" data-value="fail" onclick="toggleReportFilter(this)">
FAIL <span class="chip-count"></span>
</button>
<button class="filter-chip" data-value="warn" onclick="toggleReportFilter(this)">
WARN <span class="chip-count"></span>
</button>
<button class="filter-chip" data-value="pass" onclick="toggleReportFilter(this)">
PASS <span class="chip-count"></span>
</button>
<button class="filter-chip" data-value="skip" onclick="toggleReportFilter(this)">
SKIP <span class="chip-count"></span>
</button>
<button class="filter-chip" data-value="info" onclick="toggleReportFilter(this)">
INFO <span class="chip-count"></span>
</button>
</div>
<!-- Markdown Report -->
<div class="markdown-report">
{{ result | safe }}
</div>
<!-- Watch cross-sell prompt -->
{% if property_address and g.user %}
<div class="glass-card" style="margin-top:var(--space-8); display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-3);">
<div>
<span style="font-size:var(--text-base);">Track changes to this property?</span>
<span style="color:var(--text-secondary);font-size:var(--text-sm);display:block;margin-top:var(--space-1);">
Get notified about new permits, inspections, and complaints at {{ property_address }}.
</span>
</div>
<div class="watch-container">
<button class="obsidian-btn obsidian-btn-outline"
hx-post="/watch/add"
hx-target="closest .watch-container"
hx-swap="innerHTML"
hx-vals='{"watch_type": "address", "street_number": "{{ street_number }}", "street_name": "{{ street_name }}", "label": "{{ property_address }}"}'>
☆ Watch {{ property_address }}
</button>
</div>
</div>
{% elif property_address and not g.user %}
<div style="margin-top:var(--space-6); text-align:center; color:var(--text-secondary); font-size:var(--text-sm);">
<a href="/auth/login" style="color:var(--signal-cyan);">Sign in</a> to watch {{ property_address }} for permit updates.
</div>
{% endif %}
<!-- Analyze Another Plan -->
<div style="margin-top:var(--space-8); padding-top:var(--space-6); border-top:1px solid rgba(255,255,255,0.06); text-align:center;">
<a href="/account/analyses#upload" class="obsidian-btn obsidian-btn-outline"
style="display:inline-flex; align-items:center; gap:6px; text-decoration:none;">
+ Analyze Another Plan
</a>
</div>
</div>
{% if session_id %}
<script nonce="{{ csp_nonce }}">
// ===========================
// State Management
// ===========================
let currentPage = 0;
const sessionId = '{{ session_id }}';
const pageCount = {{ page_count or 0 }};
const extractions = {{ extractions_json | safe }};
const pageAnnotations = {{ annotations_json | safe }};
let annotationsVisible = localStorage.getItem('annotationsVisible') !== 'false';
let annotationFilter = localStorage.getItem('annotationFilter') || 'all';
// ===========================
// Annotation Color Map
// ===========================
const ANNOTATION_COLORS = {
epr_issue: '#ef4444', // red
code_reference: '#22c55e', // green
dimension: '#3b82f6', // blue
occupancy_label: '#a855f7', // purple
construction_type: '#14b8a6', // teal
scope_indicator: '#f97316', // orange
title_block: '#6b7280', // gray
stamp: '#78716c', // warm gray
structural_element: '#eab308', // yellow
general_note: '#8b5cf6', // violet
reviewer_note: '#ec4899', // pink
ai_reviewer_response: '#06b6d4', // cyan
};
const ANNOTATION_LABELS = {
epr_issue: 'EPR Issue',
code_reference: 'Code Reference',
dimension: 'Dimension',
occupancy_label: 'Occupancy',
construction_type: 'Construction Type',
scope_indicator: 'Scope Indicator',
title_block: 'Title Block',
stamp: 'Professional Stamp',
structural_element: 'Structural Element',
general_note: 'General Note',
reviewer_note: 'Reviewer Note',
ai_reviewer_response: 'AI Response',
};
// ===========================
// Annotation Helpers
// ===========================
function getAnnotationsForPage(pageNum) {
// pageNum is 0-indexed; annotations store 1-indexed page_number
return (pageAnnotations || []).filter(a => a.page_number === pageNum + 1);
}
function computeLabelPosition(ann, imgW, imgH) {
const targetX = (ann.x / 100) * imgW;
const targetY = (ann.y / 100) * imgH;
const OFFSET = Math.max(40, imgW * 0.05);
const offsets = {
'top-left': { dx: -OFFSET, dy: -OFFSET },
'top-right': { dx: OFFSET, dy: -OFFSET },
'bottom-left': { dx: -OFFSET, dy: OFFSET },
'bottom-right': { dx: OFFSET, dy: OFFSET },
};
const off = offsets[ann.anchor] || offsets['top-right'];
let labelX = targetX + off.dx;
let labelY = targetY + off.dy;
// Estimate label width — cap at 25% of image width for word wrapping
const labelText = (ann.label || '').substring(0, 80);
const fontSize = Math.max(10, Math.min(14, imgW * 0.009));
const rawTextWidth = labelText.length * fontSize * 0.55 + 12;
const maxLabelWidth = Math.max(100, imgW * 0.25);
const textWidth = Math.min(rawTextWidth, maxLabelWidth);
// Estimate wrapped line count and height
const lineCount = Math.max(1, Math.ceil(rawTextWidth / maxLabelWidth));
const textHeight = lineCount * (fontSize + 4) + 8;
// Clamp to image bounds
labelX = Math.max(5, Math.min(labelX, imgW - textWidth - 5));
labelY = Math.max(textHeight + 5, Math.min(labelY, imgH - 5));
return { targetX, targetY, labelX, labelY, fontSize, textWidth, textHeight, maxLabelWidth, labelText };
}
const SVG_NS = 'http://www.w3.org/2000/svg';
function resolveCollisions(positions, imgW, imgH) {
// Iterative collision resolution — push overlapping labels apart
const PAD = 8; // padding between labels
const MAX_ITER = 20;
for (let iter = 0; iter < MAX_ITER; iter++) {
let moved = false;
for (let i = 0; i < positions.length; i++) {
for (let j = i + 1; j < positions.length; j++) {
const a = positions[i];
const b = positions[j];
// Check bounding box overlap
const aLeft = a.labelX, aRight = a.labelX + a.textWidth + PAD;
const aTop = a.labelY - a.textHeight, aBottom = a.labelY + PAD;
const bLeft = b.labelX, bRight = b.labelX + b.textWidth + PAD;
const bTop = b.labelY - b.textHeight, bBottom = b.labelY + PAD;
if (aLeft < bRight && aRight > bLeft && aTop < bBottom && bTop < aBottom) {
// Overlap detected — push apart vertically
const overlapY = Math.min(aBottom - bTop, bBottom - aTop);
const shift = (overlapY / 2) + PAD;
// Move the one further from its target point more
if (a.labelY <= b.labelY) {
a.labelY -= shift;
b.labelY += shift;
} else {
a.labelY += shift;
b.labelY -= shift;
}
// Also nudge horizontally if heavily stacked
if (Math.abs(a.labelX - b.labelX) < 20) {
const hShift = Math.min(30, a.textWidth * 0.3);
if (a.labelX <= b.labelX) {
a.labelX -= hShift;
b.labelX += hShift;
} else {
a.labelX += hShift;
b.labelX -= hShift;
}
}
// Clamp to image bounds
a.labelX = Math.max(5, Math.min(a.labelX, imgW - a.textWidth - 5));
a.labelY = Math.max(a.textHeight + 5, Math.min(a.labelY, imgH - 5));
b.labelX = Math.max(5, Math.min(b.labelX, imgW - b.textWidth - 5));
b.labelY = Math.max(b.textHeight + 5, Math.min(b.labelY, imgH - 5));
moved = true;
}
}
}
if (!moved) break;
}
return positions;
}
function renderAnnotations(imgEl, svgEl, pageNum) {
if (!svgEl || !imgEl || !imgEl.naturalWidth) return;
const w = imgEl.naturalWidth;
const h = imgEl.naturalHeight;
svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`);
svgEl.innerHTML = '';
if (!annotationsVisible) return;
const anns = getAnnotationsForPage(pageNum)
.filter(a => annotationFilter === 'all' || a.type === annotationFilter);
// Compute all label positions first
const positions = anns.map(ann => computeLabelPosition(ann, w, h));
// Resolve collisions between overlapping labels
if (positions.length > 1) {
resolveCollisions(positions, w, h);
}
// Render with resolved positions
for (let i = 0; i < anns.length; i++) {
renderSingleAnnotationAtPosition(svgEl, anns[i], positions[i], w, h);
}
}
function renderSingleAnnotationAtPosition(svg, ann, pos, imgW, imgH) {
const color = ANNOTATION_COLORS[ann.type] || '#22c55e';
const labelText = pos.labelText || (ann.label || '').substring(0, 80);
const g = document.createElementNS(SVG_NS, 'g');
g.setAttribute('class', `annotation ann-${ann.type}`);
// Target dot
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', pos.targetX);
circle.setAttribute('cy', pos.targetY);
circle.setAttribute('r', Math.max(3, imgW * 0.003));
circle.setAttribute('fill', color);
circle.setAttribute('stroke', '#fff');
circle.setAttribute('stroke-width', '1.5');
g.appendChild(circle);
// Leader line — connect dot to center of label box
const line = document.createElementNS(SVG_NS, 'line');
line.setAttribute('class', 'ann-leader');
line.setAttribute('x1', pos.targetX);
line.setAttribute('y1', pos.targetY);
line.setAttribute('x2', pos.labelX + pos.textWidth / 2);
line.setAttribute('y2', pos.labelY - pos.textHeight / 2);
line.setAttribute('stroke', color);
line.setAttribute('stroke-width', Math.max(1, imgW * 0.001));
line.setAttribute('stroke-dasharray', '4 2');
g.appendChild(line);
// Label — use foreignObject for word-wrapping support
const fo = document.createElementNS(SVG_NS, 'foreignObject');
fo.setAttribute('class', 'ann-label-bg');
fo.setAttribute('x', pos.labelX);
fo.setAttribute('y', pos.labelY - pos.textHeight);
fo.setAttribute('width', pos.textWidth + 4);
fo.setAttribute('height', pos.textHeight + 4);
const div = document.createElement('div');
div.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
div.style.cssText = [
`background: ${color}`,
'opacity: 0.92',
`border-radius: 3px`,
`padding: 3px 6px`,
`color: #fff`,
`font-size: ${pos.fontSize}px`,
`font-family: system-ui, -apple-system, sans-serif`,
`line-height: ${pos.fontSize + 4}px`,
`word-wrap: break-word`,
`overflow-wrap: break-word`,
`white-space: normal`,
`max-width: ${pos.textWidth}px`,
`overflow: hidden`,
].join('; ');
div.textContent = labelText;
fo.appendChild(div);
g.appendChild(fo);
svg.appendChild(g);
}
// Backwards-compatible version (used by comparison views)
function renderSingleAnnotation(svg, ann, imgW, imgH) {
const pos = computeLabelPosition(ann, imgW, imgH);
renderSingleAnnotationAtPosition(svg, ann, pos, imgW, imgH);
}
function renderThumbnailAnnotations(imgEl, pageNum) {
const svgEl = imgEl.parentElement.querySelector('.annotation-thumbnail');
if (!svgEl || !imgEl.naturalWidth) return;
const w = imgEl.naturalWidth;
const h = imgEl.naturalHeight;
svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`);
svgEl.innerHTML = '';
if (!annotationsVisible) return;
const anns = getAnnotationsForPage(pageNum)
.filter(a => annotationFilter === 'all' || a.type === annotationFilter);
for (const ann of anns) {
const color = ANNOTATION_COLORS[ann.type] || '#22c55e';
const cx = (ann.x / 100) * w;
const cy = (ann.y / 100) * h;
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', cx);
circle.setAttribute('cy', cy);
circle.setAttribute('r', Math.max(6, w * 0.008));
circle.setAttribute('fill', color);
circle.setAttribute('stroke', '#fff');
circle.setAttribute('stroke-width', '2');
circle.setAttribute('opacity', '0.9');
svgEl.appendChild(circle);
}
}
function renderComparisonAnnotations(side) {
const select = document.getElementById(`compare-${side}`);
const img = document.getElementById(`compare-${side}-img`);
const svg = document.getElementById(`compare-${side}-annotations`);
if (!select || !img || !svg) return;
const pageNum = parseInt(select.value);
renderAnnotations(img, svg, pageNum);
}
function toggleAnnotations() {
annotationsVisible = !annotationsVisible;
localStorage.setItem('annotationsVisible', annotationsVisible);
const btn = document.getElementById('toggle-annotations');
btn.textContent = `Annotations: ${annotationsVisible ? 'ON' : 'OFF'}`;
if (annotationsVisible) {
btn.classList.add('ann-toggle-on');
} else {
btn.classList.remove('ann-toggle-on');
}
refreshAllAnnotations();
}
function filterAnnotations() {
annotationFilter = document.getElementById('annotation-filter').value;
localStorage.setItem('annotationFilter', annotationFilter);
// Filter thumbnail visibility — hide pages with no matching annotations
var totalPages = 0;
var visiblePages = 0;
document.querySelectorAll('.thumbnail-item').forEach(function(item) {
totalPages++;
if (annotationFilter === 'all') {
item.style.display = '';
visiblePages++;
} else {
var pageIdx = parseInt(item.dataset.page || '0');
var pageAnns = (pageAnnotations || []).filter(function(a) {
return a.page_number === (pageIdx + 1) && a.type === annotationFilter;
});
if (pageAnns.length > 0) {
item.style.display = '';
visiblePages++;
} else {
item.style.display = 'none';
}
}
});
// Show page count indicator
var countEl = document.getElementById('filter-page-count');
if (countEl) {
if (annotationFilter === 'all') {
countEl.style.display = 'none';
} else {
countEl.textContent = 'Showing ' + visiblePages + ' of ' + totalPages + ' pages';
countEl.style.display = '';
}
}
refreshAllAnnotations();
}
function refreshAllAnnotations() {
// Re-render all thumbnail annotations
document.querySelectorAll('.annotation-thumbnail').forEach(svg => {
const pageNum = parseInt(svg.dataset.page);
const img = svg.parentElement.querySelector('img');
if (img && img.naturalWidth) {
renderThumbnailAnnotations(img, pageNum);
}
});
// Re-render detail card annotations
const detailImg = document.getElementById('detail-img');
const detailSvg = document.getElementById('detail-annotations');
if (detailImg && detailSvg && detailImg.naturalWidth) {
renderAnnotations(detailImg, detailSvg, currentPage);
}
// Re-render lightbox annotations
const lbImg = document.getElementById('lightbox-img');
const lbSvg = document.getElementById('lightbox-annotations');
if (lbImg && lbSvg && lbImg.naturalWidth) {
renderAnnotations(lbImg, lbSvg, currentPage);
}
// Re-render comparison annotations
renderComparisonAnnotations('left');
renderComparisonAnnotations('right');
}
// ===========================
// Core Functions
// ===========================
function openPageDetail(pageNum) {
currentPage = pageNum;
const extraction = extractions[pageNum] || {};
// Update detail card content
document.getElementById('detail-page-num').textContent = pageNum + 1;
document.getElementById('detail-img').src = `/plan-images/${sessionId}/${pageNum}`;
document.getElementById('meta-sheet').textContent = extraction.sheet_id || 'N/A';
document.getElementById('meta-address').textContent = extraction.address || 'N/A';
document.getElementById('meta-firm').textContent = extraction.firm || 'N/A';
document.getElementById('meta-stamp').textContent = extraction.has_professional_stamp ? 'Yes' : 'No';
// Show detail card
document.getElementById('detail-card').style.display = 'block';
// Scroll to detail card
document.getElementById('detail-card').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function closeDetail() {
document.getElementById('detail-card').style.display = 'none';
}
function openLightbox(pageNum) {
currentPage = pageNum;
updateLightbox();
document.getElementById('lightbox').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
document.getElementById('lightbox').style.display = 'none';
document.body.style.overflow = '';
// Exit lasso mode if active
if (lassoMode) toggleLassoMode();
}
function prevPage() {
currentPage = (currentPage - 1 + pageCount) % pageCount;
updateLightbox();
}
function nextPage() {
currentPage = (currentPage + 1) % pageCount;
updateLightbox();
}
function updateLightbox() {
const extraction = extractions[currentPage] || {};
document.getElementById('lightbox-img').src = `/plan-images/${sessionId}/${currentPage}`;
document.getElementById('lightbox-page-info').textContent =
`Page ${currentPage + 1} of ${pageCount} — ${extraction.sheet_id || 'No ID'}`;
// Hide prev/next buttons if single page
if (pageCount <= 1) {
document.querySelector('.lightbox-prev').style.display = 'none';
document.querySelector('.lightbox-next').style.display = 'none';
}
resetZoom();
}
// ===========================
// Lightbox Zoom & Pan
// ===========================
let zoomScale = 1;
const ZOOM_MIN = 1;
const ZOOM_MAX = 6;
const ZOOM_STEP = 0.3;
let panX = 0, panY = 0;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let panStartX = 0, panStartY = 0;
function applyTransform() {
const el = document.getElementById('lightbox-zoomable');
if (!el) return;
el.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`;
document.getElementById('zoom-level').textContent = Math.round(zoomScale * 100) + '%';
// Counter-scale the SVG annotation layer so text renders at native DPI
// (CSS transform: scale() on the parent rasterizes text at original size,
// causing fuzzy labels when zoomed in)
const svg = document.getElementById('lightbox-annotations');
if (svg) {
const inv = 1 / zoomScale;
// Scale the SVG inversely — it will render at 1:1 pixels
// but still occupy the same visual space over the image
svg.style.transform = `scale(${inv})`;
svg.style.transformOrigin = '0 0';
// Expand the SVG element dimensions so it covers the full image area
svg.style.width = (zoomScale * 100) + '%';
svg.style.height = (zoomScale * 100) + '%';
}
}
function clampPan() {
const viewport = document.getElementById('lightbox-viewport');
const el = document.getElementById('lightbox-zoomable');
if (!viewport || !el) return;
const vw = viewport.clientWidth;
const vh = viewport.clientHeight;
const img = document.getElementById('lightbox-img');
const iw = (img ? img.clientWidth : vw) * zoomScale;
const ih = (img ? img.clientHeight : vh) * zoomScale;
// Only allow pan if zoomed content is larger than viewport
if (iw <= vw) { panX = 0; }
else { panX = Math.min(0, Math.max(vw - iw, panX)); }
if (ih <= vh) { panY = 0; }
else { panY = Math.min(0, Math.max(vh - ih, panY)); }
}
function resetZoom() {
zoomScale = 1;
panX = 0;
panY = 0;
applyTransform(); // applyTransform is patched to call updateMinimap()
const viewport = document.getElementById('lightbox-viewport');
if (viewport) viewport.style.cursor = lassoMode ? 'crosshair' : 'grab';
}
function zoomIn() {
zoomScale = Math.min(ZOOM_MAX, zoomScale + ZOOM_STEP);
clampPan();
applyTransform();
}
function zoomOut() {
zoomScale = Math.max(ZOOM_MIN, zoomScale - ZOOM_STEP);
clampPan();
applyTransform();
}
function zoomAtPoint(delta, clientX, clientY) {
const viewport = document.getElementById('lightbox-viewport');
if (!viewport) return;
const rect = viewport.getBoundingClientRect();
// Mouse position relative to viewport
const mx = clientX - rect.left;
const my = clientY - rect.top;
const oldScale = zoomScale;
zoomScale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomScale + delta));
// Adjust pan so the point under the cursor stays fixed
const ratio = zoomScale / oldScale;
panX = mx - ratio * (mx - panX);
panY = my - ratio * (my - panY);
clampPan();
applyTransform();
viewport.style.cursor = zoomScale > 1 ? 'grab' : 'grab';
}
// Wheel zoom
(function() {
const viewport = document.getElementById('lightbox-viewport');
if (!viewport) return;
viewport.addEventListener('wheel', function(e) {
e.preventDefault();
const delta = e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP;
zoomAtPoint(delta, e.clientX, e.clientY);
}, { passive: false });
// Mouse drag to pan
viewport.addEventListener('mousedown', function(e) {
if (lassoMode) return; // lasso handles its own mousedown
if (zoomScale <= 1) return;
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = panX;
panStartY = panY;
viewport.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
panX = panStartX + (e.clientX - dragStartX);
panY = panStartY + (e.clientY - dragStartY);
clampPan();
applyTransform();
});
document.addEventListener('mouseup', function() {
if (isDragging) {
isDragging = false;
const vp = document.getElementById('lightbox-viewport');
if (vp) vp.classList.remove('dragging');
}
});
// Double-click to zoom in / reset
viewport.addEventListener('dblclick', function(e) {
if (lassoMode) return;
e.preventDefault();
if (zoomScale > 1.2) {
resetZoom();
} else {
zoomAtPoint(1.5, e.clientX, e.clientY);
}
});
// Touch pinch-to-zoom
let lastTouchDist = 0;
let lastTouchMidX = 0, lastTouchMidY = 0;
viewport.addEventListener('touchstart', function(e) {
if (e.touches.length === 2) {
lastTouchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
e.preventDefault();
} else if (e.touches.length === 1 && zoomScale > 1) {
isDragging = true;
dragStartX = e.touches[0].clientX;
dragStartY = e.touches[0].clientY;
panStartX = panX;
panStartY = panY;
e.preventDefault();
}
}, { passive: false });
viewport.addEventListener('touchmove', function(e) {
if (e.touches.length === 2) {
e.preventDefault();
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const delta = (dist - lastTouchDist) * 0.008;
zoomAtPoint(delta, midX, midY);
lastTouchDist = dist;
lastTouchMidX = midX;
lastTouchMidY = midY;
} else if (e.touches.length === 1 && isDragging) {
e.preventDefault();
panX = panStartX + (e.touches[0].clientX - dragStartX);
panY = panStartY + (e.touches[0].clientY - dragStartY);
clampPan();
applyTransform();
}
}, { passive: false });
viewport.addEventListener('touchend', function() {
isDragging = false;
lastTouchDist = 0;
});
})();
// ===========================
// Comparison View
// ===========================
function openComparison() {
populateComparisonDropdowns();
// Set initial selections
const leftSelect = document.getElementById('compare-left');
const rightSelect = document.getElementById('compare-right');
if (pageCount >= 2) {
leftSelect.value = '0';
rightSelect.value = '1';
} else if (pageCount === 1) {
leftSelect.value = '0';
rightSelect.value = '0';
}
updateComparisonPanel('left');
updateComparisonPanel('right');
document.getElementById('comparison-panel').style.display = 'block';
document.getElementById('comparison-panel').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function populateComparisonDropdowns() {
const leftSelect = document.getElementById('compare-left');
const rightSelect = document.getElementById('compare-right');
leftSelect.innerHTML = '';
rightSelect.innerHTML = '';
for (let i = 0; i < pageCount; i++) {
const extraction = extractions[i] || {};
const label = `Page ${i + 1} — ${extraction.sheet_id || 'No ID'}`;
const leftOption = document.createElement('option');
leftOption.value = i;
leftOption.textContent = label;
leftSelect.appendChild(leftOption);
const rightOption = document.createElement('option');
rightOption.value = i;
rightOption.textContent = label;
rightSelect.appendChild(rightOption);
}
}
function updateComparisonPanel(side) {
const select = document.getElementById(`compare-${side}`);
const img = document.getElementById(`compare-${side}-img`);
const pageNum = parseInt(select.value);
img.src = `/plan-images/${sessionId}/${pageNum}`;
}
function closeComparison() {
document.getElementById('comparison-panel').style.display = 'none';
}
// ===========================
// Download Functions
// ===========================
function downloadPage() {
window.location.href = `/plan-images/${sessionId}/${currentPage}?download=1`;
}
function downloadAllPages() {
window.location.href = `/plan-images/${sessionId}/download-all`;
}
function downloadComparison() {
const leftPage = parseInt(document.getElementById('compare-left').value);
const rightPage = parseInt(document.getElementById('compare-right').value);
// Download left page
window.location.href = `/plan-images/${sessionId}/${leftPage}?download=1`;
// Download right page after short delay
setTimeout(() => {
window.location.href = `/plan-images/${sessionId}/${rightPage}?download=1`;
}, 500);
}
function printReport() {
// Add printing class to body so @media print CSS hides non-report elements
document.body.classList.add('printing-report');
window.print();
// Remove class after print dialog closes (or cancels)
window.addEventListener('afterprint', function onAfterPrint() {
document.body.classList.remove('printing-report');
window.removeEventListener('afterprint', onAfterPrint);
});
// Fallback: remove after 1 second if afterprint doesn't fire
setTimeout(() => {
document.body.classList.remove('printing-report');
}, 1000);
}
// ===========================
// Print Functions
// ===========================
function printPage() {
const imgSrc = `/plan-images/${sessionId}/${currentPage}`;
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>Print Page ${currentPage + 1}</title>
<style nonce="{{ csp_nonce }}">
body { margin: 0; padding: 0; }
img { max-width: 100%; height: auto; }
</style>
<link rel="stylesheet" href="/static/mobile.css">
<meta name="csrf-token" content="{{ csrf_token }}">
<script nonce="{{ csp_nonce }}">
document.addEventListener('htmx:configRequest', function(e) {
var token = document.querySelector('meta[name="csrf-token"]');
if (token) e.detail.headers['X-CSRFToken'] = token.getAttribute('content');
});
</script>
</head>
<body>
<img src="${imgSrc}" onload="window.print(); window.close();">
</body>
</html>
`);
printWindow.document.close();
}
function printReport() {
window.print();
}
// ===========================
// Email Functions
// ===========================
function emailAnalysis() {
openEmailModal('full');
}
function emailComparison() {
const leftPage = parseInt(document.getElementById('compare-left').value);
const rightPage = parseInt(document.getElementById('compare-right').value);
openEmailModal(`comparison:${leftPage},${rightPage}`);
}
function openEmailModal(context) {
document.getElementById('email-context').value = context;
document.getElementById('email-modal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function sendEmail(event) {
event.preventDefault();
const recipient = document.getElementById('email-recipient').value;
const message = document.getElementById('email-message').value;
const context = document.getElementById('email-context').value;
fetch(`/plan-analysis/${sessionId}/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({
session_id: sessionId,
recipient: recipient,
message: message,
context: context
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Email sent successfully!');
closeEmailModal();
} else {
alert('Error sending email: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error sending email: ' + error.message);
});
}
function closeEmailModal() {
document.getElementById('email-modal').style.display = 'none';
document.body.style.overflow = '';
document.getElementById('email-form').reset();
}
// ===========================
// Keyboard Navigation
// ===========================
document.addEventListener('keydown', (e) => {
const lightbox = document.getElementById('lightbox');
if (lightbox && lightbox.style.display !== 'none') {
if (e.key === 'ArrowLeft') {
e.preventDefault();
prevPage();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
nextPage();
} else if (e.key === 'Escape') {
e.preventDefault();
// Close lasso mode first, then legend panel, then lightbox
if (lassoMode) {
toggleLassoMode();
} else {
const panel = document.getElementById('lightbox-legend-panel');
if (panel && panel.classList.contains('open')) {
toggleLegendPanel();
} else {
closeLightbox();
}
}
}
}
});
// ===========================
// Window Resize Handler
// ===========================
let _resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(refreshAllAnnotations, 200);
});
// ===========================
// Annotation Legend
// ===========================
function toggleLegend() {
const el = document.getElementById('annotation-legend');
if (!el.innerHTML) buildLegend();
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function buildLegend() {
const el = document.getElementById('annotation-legend');
let html = '';
for (const [type, color] of Object.entries(ANNOTATION_COLORS)) {
const label = ANNOTATION_LABELS[type] || type;
html += '<div class="legend-item">' +
'<span class="legend-swatch" style="background:' + color + '"></span>' +
'<span>' + label + '</span></div>';
}
el.innerHTML = html;
}
// Close legend when clicking outside
document.addEventListener('click', function(e) {
const legend = document.getElementById('annotation-legend');
const btn = document.getElementById('legend-btn');
if (legend && legend.style.display !== 'none' &&
!legend.contains(e.target) && e.target !== btn) {
legend.style.display = 'none';
}
});
// ===========================
// Lasso Zoom Mode
// ===========================
let lassoMode = false;
let lassoActive = false;
let lassoStartX = 0, lassoStartY = 0;
function toggleLassoMode() {
lassoMode = !lassoMode;
const btn = document.getElementById('lasso-btn');
const viewport = document.getElementById('lightbox-viewport');
if (lassoMode) {
btn.classList.add('lasso-mode-on');
viewport.classList.add('lasso-active');
viewport.style.cursor = 'crosshair';
} else {
btn.classList.remove('lasso-mode-on');
viewport.classList.remove('lasso-active');
viewport.style.cursor = zoomScale > 1 ? 'grab' : 'grab';
// Hide lasso rect if visible
const rect = document.getElementById('lasso-rect');
if (rect) rect.style.display = 'none';
}
}
// Lasso mouse events on the lightbox-content (so rect can extend beyond viewport)
(function initLasso() {
const content = document.querySelector('.lightbox-content');
const viewport = document.getElementById('lightbox-viewport');
if (!content || !viewport) return;
viewport.addEventListener('mousedown', function(e) {
if (!lassoMode) return;
e.preventDefault();
e.stopPropagation();
lassoActive = true;
const contentRect = content.getBoundingClientRect();
lassoStartX = e.clientX - contentRect.left;
lassoStartY = e.clientY - contentRect.top;
const rect = document.getElementById('lasso-rect');
rect.style.left = lassoStartX + 'px';
rect.style.top = lassoStartY + 'px';
rect.style.width = '0';
rect.style.height = '0';
rect.style.display = 'block';
}, true);
document.addEventListener('mousemove', function(e) {
if (!lassoActive) return;
e.preventDefault();
const contentRect = content.getBoundingClientRect();
const curX = e.clientX - contentRect.left;
const curY = e.clientY - contentRect.top;
const rect = document.getElementById('lasso-rect');
const x = Math.min(lassoStartX, curX);
const y = Math.min(lassoStartY, curY);
const w = Math.abs(curX - lassoStartX);
const h = Math.abs(curY - lassoStartY);
rect.style.left = x + 'px';
rect.style.top = y + 'px';
rect.style.width = w + 'px';
rect.style.height = h + 'px';
});
document.addEventListener('mouseup', function(e) {
if (!lassoActive) return;
lassoActive = false;
const rect = document.getElementById('lasso-rect');
rect.style.display = 'none';
const contentRect = content.getBoundingClientRect();
const curX = e.clientX - contentRect.left;
const curY = e.clientY - contentRect.top;
const selW = Math.abs(curX - lassoStartX);
const selH = Math.abs(curY - lassoStartY);
// Ignore tiny selections (accidental clicks)
if (selW < 20 || selH < 20) return;
// Calculate selection center relative to viewport
const vpRect = viewport.getBoundingClientRect();
const selCenterX = Math.min(lassoStartX, curX) + selW / 2 + contentRect.left - vpRect.left;
const selCenterY = Math.min(lassoStartY, curY) + selH / 2 + contentRect.top - vpRect.top;
// Convert selection center from viewport coords to image coords (accounting for current pan/zoom)
const imgX = (selCenterX - panX) / zoomScale;
const imgY = (selCenterY - panY) / zoomScale;
// Calculate zoom needed to fit selection in viewport
const vpW = viewport.clientWidth;
const vpH = viewport.clientHeight;
const fitZoom = Math.min(vpW / (selW / zoomScale), vpH / (selH / zoomScale));
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, fitZoom));
// Set new zoom and pan to center the selected area
zoomScale = newZoom;
panX = vpW / 2 - imgX * zoomScale;
panY = vpH / 2 - imgY * zoomScale;
clampPan();
applyTransform();
updateMinimap();
// Exit lasso mode after zooming
toggleLassoMode();
});
})();
// ===========================
// Minimap
// ===========================
function updateMinimap() {
const minimap = document.getElementById('minimap');
const minimapImg = document.getElementById('minimap-img');
const minimapVp = document.getElementById('minimap-viewport');
const viewport = document.getElementById('lightbox-viewport');
const img = document.getElementById('lightbox-img');
if (!minimap || !minimapImg || !minimapVp || !viewport || !img) return;
// Show minimap only when zoomed in
if (zoomScale <= 1.1) {
minimap.classList.remove('visible');
return;
}
minimap.classList.add('visible');
// Set minimap image to current page
if (minimapImg.src !== img.src) {
minimapImg.src = img.src;
}
// Calculate viewport indicator position
const imgW = img.clientWidth;
const imgH = img.clientHeight;
const minimapW = minimap.clientWidth;
const minimapH = minimapImg.clientHeight || (minimapW * (imgH / imgW));
if (minimapH <= 0 || imgW <= 0) return;
const scale = minimapW / (imgW * zoomScale);
const vpW = viewport.clientWidth * scale;
const vpH = viewport.clientHeight * scale;
const vpX = -panX * scale;
const vpY = -panY * scale;
minimapVp.style.left = Math.max(0, vpX) + 'px';
minimapVp.style.top = Math.max(0, vpY) + 'px';
minimapVp.style.width = Math.min(minimapW, vpW) + 'px';
minimapVp.style.height = Math.min(minimapH, vpH) + 'px';
}
// Hook minimap update into applyTransform
(function patchApplyTransform() {
const origApplyTransform = applyTransform;
applyTransform = function() {
origApplyTransform();
updateMinimap();
};
})();
// ===========================
// Left-Side Legend Panel
// ===========================
// Track per-type visibility (separate from the global annotationsVisible toggle)
let typeVisibility = {};
(function initTypeVisibility() {
const saved = localStorage.getItem('annotationTypeVisibility');
if (saved) {
try { typeVisibility = JSON.parse(saved); } catch(e) {}
}
// Default: all types visible
for (const type in ANNOTATION_COLORS) {
if (!(type in typeVisibility)) typeVisibility[type] = true;
}
})();
function saveTypeVisibility() {
localStorage.setItem('annotationTypeVisibility', JSON.stringify(typeVisibility));
}
function toggleLegendPanel() {
const panel = document.getElementById('lightbox-legend-panel');
const btn = document.getElementById('legend-panel-toggle-btn');
const isOpen = panel.classList.contains('open');
if (isOpen) {
panel.classList.remove('open');
btn.classList.remove('active');
} else {
panel.classList.add('open');
btn.classList.add('active');
buildLegendPanel();
}
}
function buildLegendPanel() {
const container = document.getElementById('legend-panel-items');
if (!container) return;
// Count annotations per type for current page
const anns = getAnnotationsForPage(currentPage);
const typeCounts = {};
for (const ann of anns) {
typeCounts[ann.type] = (typeCounts[ann.type] || 0) + 1;
}
// Count total across all pages
const totalCounts = {};
for (const ann of (pageAnnotations || [])) {
totalCounts[ann.type] = (totalCounts[ann.type] || 0) + 1;
}
let html = '';
for (const [type, color] of Object.entries(ANNOTATION_COLORS)) {
const label = ANNOTATION_LABELS[type] || type;
const count = typeCounts[type] || 0;
const total = totalCounts[type] || 0;
const isOn = typeVisibility[type] !== false;
const disabledClass = isOn ? '' : ' disabled';
const checkMark = isOn ? '✓' : '';
html += '<div class="legend-toggle-item' + disabledClass + '" data-type="' + type + '" onclick="toggleAnnotationType(\'' + type + '\')">' +
'<div class="legend-toggle-check" style="color:' + color + ';">' + checkMark + '</div>' +
'<span class="legend-toggle-swatch" style="background:' + color + ';"></span>' +
'<span class="legend-toggle-label">' + label + '</span>' +
'<span class="legend-toggle-count">' + (count > 0 ? count : '') + '</span>' +
'</div>';
}
container.innerHTML = html;
}
function toggleAnnotationType(type) {
typeVisibility[type] = !typeVisibility[type];
saveTypeVisibility();
buildLegendPanel();
refreshAllAnnotations();
}
function legendShowAll() {
for (const type in ANNOTATION_COLORS) {
typeVisibility[type] = true;
}
saveTypeVisibility();
buildLegendPanel();
refreshAllAnnotations();
}
function legendHideAll() {
for (const type in ANNOTATION_COLORS) {
typeVisibility[type] = false;
}
saveTypeVisibility();
buildLegendPanel();
refreshAllAnnotations();
}
// Patch getAnnotationsForPage to respect per-type visibility
const _origGetAnnotationsForPage = getAnnotationsForPage;
getAnnotationsForPage = function(pageNum) {
return _origGetAnnotationsForPage(pageNum).filter(function(a) {
return typeVisibility[a.type] !== false;
});
};
// Update legend panel when page changes
(function patchUpdateLightbox() {
const origUpdateLightbox = updateLightbox;
updateLightbox = function() {
origUpdateLightbox();
// Refresh legend panel if open
const panel = document.getElementById('lightbox-legend-panel');
if (panel && panel.classList.contains('open')) {
buildLegendPanel();
}
};
})();
// ===========================
// Enhanced Keyboard Navigation
// ===========================
(function patchKeyboard() {
// Add zoom keyboard shortcuts
document.addEventListener('keydown', function(e) {
const lightbox = document.getElementById('lightbox');
if (!lightbox || lightbox.style.display === 'none') return;
if (e.key === '+' || e.key === '=') {
e.preventDefault();
zoomIn();
} else if (e.key === '-') {
e.preventDefault();
zoomOut();
} else if (e.key === '0') {
e.preventDefault();
resetZoom();
} else if (e.key === 'l' || e.key === 'L') {
e.preventDefault();
toggleLegendPanel();
}
});
})();
// ===========================
// Restore Persisted State
// ===========================
(function initAnnotationState() {
// Sync toggle button with restored state
const btn = document.getElementById('toggle-annotations');
if (btn) {
btn.textContent = 'Annotations: ' + (annotationsVisible ? 'ON' : 'OFF');
if (!annotationsVisible) btn.classList.remove('ann-toggle-on');
}
// Sync filter dropdown with restored state
const filter = document.getElementById('annotation-filter');
if (filter && annotationFilter !== 'all') filter.value = annotationFilter;
})();
</script>
{% endif %}
{# Report status filter JS — runs for both Quick Check (no session_id) and async results #}
<script nonce="{{ csp_nonce }}">
// ===========================
// Report Status Filters
// ===========================
(function() {
var report = document.querySelector('.markdown-report');
if (!report) return;
// Find all h3 elements matching "STATUS — EPR-ID: Rule"
var headings = report.querySelectorAll('h3');
if (!headings.length) return;
var statusPattern = /^(PASS|FAIL|WARN|SKIP|INFO)\s*[—\-]/;
var counts = { all: 0, pass: 0, fail: 0, warn: 0, skip: 0, info: 0 };
var statusColors = {
pass: '#22c55e', fail: '#ef4444', warn: '#f59e0b',
skip: '#6b7280', info: '#3b82f6'
};
headings.forEach(function(h3) {
var match = h3.textContent.match(statusPattern);
if (!match) return;
var status = match[1].toLowerCase();
// Replace plain status text with a colored pill badge
var statusUpper = match[1];
var badgeColor = statusColors[status] || '#6b7280';
h3.innerHTML = h3.innerHTML.replace(
statusUpper,
'<span class="status-badge" style="background:' + badgeColor + '">' + statusUpper + '</span>'
);
// Wrap h3 + its sibling content in a filterable div
var wrapper = document.createElement('div');
wrapper.className = 'epr-check-item';
wrapper.dataset.status = status;
h3.parentNode.insertBefore(wrapper, h3);
wrapper.appendChild(h3);
// Move subsequent siblings until next h2 or h3
while (wrapper.nextSibling) {
var next = wrapper.nextSibling;
if (next.nodeType === 1 && (next.tagName === 'H3' || next.tagName === 'H2')) break;
wrapper.appendChild(next);
}
counts[status]++;
counts.all++;
});
// Update chip counts and show the filter bar
var bar = document.getElementById('report-filter-bar');
if (bar && counts.all > 0) {
bar.style.display = 'flex';
bar.querySelectorAll('.filter-chip').forEach(function(chip) {
var val = chip.dataset.value;
var countSpan = chip.querySelector('.chip-count');
if (countSpan && counts[val] !== undefined) {
countSpan.textContent = '(' + counts[val] + ')';
}
// Hide chips with zero count (except "all")
if (val !== 'all' && counts[val] === 0) {
chip.style.display = 'none';
}
});
}
})();
var _reportFilter = 'all';
function toggleReportFilter(chip) {
var value = chip.dataset.value;
// Update active state
document.querySelectorAll('#report-filter-bar .filter-chip').forEach(function(c) {
c.classList.remove('active');
});
chip.classList.add('active');
_reportFilter = value;
// Show/hide EPR check items
document.querySelectorAll('.epr-check-item').forEach(function(item) {
if (value === 'all' || item.dataset.status === value) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
}
</script>