'use strict'; // PaddyLiteX: resolve class results beside the running app.js. const paddyClassResultsUrl = new URL('per_class.json', document.currentScript?.src || document.baseURI); // Application state is shared by the detection and benchmark views. const getElement = (id) => document.getElementById(id); const formatNumber = (value) => new Intl.NumberFormat('en-US').format(value); const performanceMetrics = { map5095: { label: 'mAP@0.5:0.95', unit: '%' }, map50: { label: 'mAP@0.5', unit: '%' }, precision: { label: 'Precision', unit: '%' }, recall: { label: 'Recall', unit: '%' }, }; const complexityMetrics = { parameters: { label: 'Parameters', axisLabel: 'Parameters (millions)', scale: 1000000, unit: 'M', }, gflops: { label: 'GFLOPs', axisLabel: 'GFLOPs', scale: 1, unit: 'G' }, }; let benchmarkData; let currentStudy = 'main'; let selectedFile = null; let previewUrl = null; let comparisonRunning = false; let latestResults = null; let classData = null; let classDataError = ''; const classMetricLabels = { map5095: 'AP@0.5:0.95', map50: 'AP@0.5', precision: 'Precision', recall: 'Recall', }; // Dataset class IDs supplied for this thesis. Keep names independent of scores. const paddyClassNames = [ 'Rice__BacterialLeafBlight', 'Rice__BrownSpot', 'Rice__Healthy', 'Rice__Hispa', 'Rice__LeafBlast', 'Rice__LeafScald', 'Rice__LeafSmut', 'Rice__NarrowBrownLeafSpot', 'Rice__NeckBlast', ]; function classRows() { const supplied = new Map((classData?.classes || []).map((row) => [row.id, row])); return paddyClassNames.map((name, id) => ({ id, name, models: supplied.get(id)?.models || {}, })); } function escapeHtml(value) { const entities = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return String(value).replace(/[&<>"']/g, (character) => entities[character]); } function displayModelName(model) { // Older local configurations still use the previous baseline label. return model.label.replace(/\bDenseNet121\b/g, 'Yolov8n_DenseNet121'); } function showView(viewName) { document.querySelectorAll('.view').forEach((view) => { const active = view.id === viewName; view.hidden = !active; view.classList.toggle('active', active); }); document.querySelectorAll('.tab').forEach((button) => { const active = button.dataset.view === viewName; button.classList.toggle('active', active); button.setAttribute('aria-selected', String(active)); }); } async function readResponse(response) { let payload; try { payload = await response.json(); } catch { throw new Error('The server returned an unexpected response. Please try again.'); } if (!response.ok) { throw new Error( typeof payload.detail === 'string' ? payload.detail : 'The request could not be completed.', ); } return payload; } // Detection studio function resultHeading(model) { return `

${escapeHtml(model.role)}

${escapeHtml(displayModelName(model))}

`; } function showEmptyResults(message = 'Upload an image to compare predictions.') { if (!benchmarkData) return; getElement('results').innerHTML = benchmarkData.main .map( (model) => `
${resultHeading(model)}

${escapeHtml(message)}

${escapeHtml(model.id)}
`, ) .join(''); } function showLoadingResults() { getElement('results').innerHTML = benchmarkData.main .map( (model) => `
${resultHeading(model)}

Comparing models…

Identical image and settings
`, ) .join(''); } function renderResults(results) { const classMappings = results .filter((result) => result.status === 'success') .map((result) => JSON.stringify(Object.entries(result.class_names).sort())); if (new Set(classMappings).size > 1) { getElement('message').textContent = 'The checkpoints have different class mappings. Verify their dataset configuration before treating this as a matched comparison.'; } getElement('results').innerHTML = benchmarkData.main .map((model) => { const result = results.find((item) => item.id === model.id); if (!result || result.status !== 'success') { const description = result?.status === 'unavailable' ? 'Model unavailable' : 'Inference error'; return `
${resultHeading(model)}

${escapeHtml(result?.message || 'No result returned.')}

${description}
`; } const detections = result.detections; const detectionRows = detections.length ? detections .map( (detection) => `
${escapeHtml(detection.class_name)} ${(detection.confidence * 100).toFixed(1)}%
`, ) .join('') : '

No boxes exceeded the selected threshold. This does not confirm that the leaf is healthy.

'; return `
${resultHeading(model)}
${escapeHtml(displayModelName(model))} annotated detection result
${detections.length} detection${detections.length === 1 ? '' : 's'} ${detectionRows} Download annotated image
`; }) .join(''); } function updateControls() { getElement('compare').disabled = !selectedFile || comparisonRunning || !benchmarkData; getElement('compare').textContent = comparisonRunning ? 'Comparing models…' : 'Compare all three models'; ['file', 'clear', 'confidence', 'image-size'].forEach((id) => { getElement(id).disabled = comparisonRunning; }); } function clearFile() { if (comparisonRunning) return; if (previewUrl) URL.revokeObjectURL(previewUrl); selectedFile = null; previewUrl = null; latestResults = null; getElement('file').value = ''; getElement('input-preview').hidden = true; getElement('input-preview').removeAttribute('src'); getElement('upload-hint').hidden = false; getElement('clear').hidden = true; getElement('filename').textContent = 'No image selected'; getElement('message').textContent = ''; showEmptyResults(); updateControls(); } // JPG upload compatibility: some files have an alias or no MIME type. function isSupportedImageFile(file) { if (!file) return false; const imageType = (file.type || '').toLowerCase(); const supportedTypes = [ 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/png', 'image/webp', ]; if (supportedTypes.includes(imageType)) return true; // The existing image decoder still verifies that this is an actual image. return ( ['', 'application/octet-stream'].includes(imageType) && /\.(jpg|jpeg|png|webp)$/i.test(file.name || '') ); } async function selectFile(file) { if (comparisonRunning || !file) return; getElement('message').textContent = ''; if (!isSupportedImageFile(file)) { getElement('message').textContent = 'Choose a JPG, JPEG, PNG, or WebP image.'; return; } if (file.size > 10 * 1024 * 1024) { getElement('message').textContent = 'Image exceeds 10 MB. Choose a smaller file.'; return; } let bitmap; try { bitmap = await createImageBitmap(file); if (bitmap.width * bitmap.height > 12000000) { throw new Error('Image exceeds 12 megapixels. Resize it first.'); } } catch (error) { getElement('message').textContent = error.message.startsWith('Image exceeds') ? error.message : 'The file cannot be decoded as an image.'; return; } finally { bitmap?.close(); } if (previewUrl) URL.revokeObjectURL(previewUrl); selectedFile = file; latestResults = null; previewUrl = URL.createObjectURL(file); getElement('input-preview').src = previewUrl; getElement('input-preview').hidden = false; getElement('upload-hint').hidden = true; getElement('clear').hidden = false; getElement('filename').textContent = `${file.name} · ${(file.size / 1024).toFixed(0)} KB`; showEmptyResults(); updateControls(); } async function compareModels() { if (!selectedFile || comparisonRunning || !benchmarkData) return; comparisonRunning = true; updateControls(); showLoadingResults(); getElement('message').textContent = ''; const form = new FormData(); form.append('file', selectedFile); form.append('confidence', getElement('confidence').value); form.append('image_size', getElement('image-size').value); try { const response = await PaddyLiteXBrowser.fetch('/api/compare', { method: 'POST', body: form }); latestResults = await readResponse(response); renderResults(latestResults.results); } catch (error) { showEmptyResults('Comparison could not complete. See the message above.'); getElement('message').textContent = error.message; } finally { comparisonRunning = false; updateControls(); } } // Benchmark charts: both bar charts and the scatter plot use these selectors. function formatMetric(value, metric) { if (performanceMetrics[metric]) return `${value.toFixed(2)}%`; if (metric === 'parameters') return `${(value / 1000000).toFixed(2)} M`; return `${value.toFixed(1)} G`; } function axisScale(maximum) { // Choose round tick values and keep all points inside the plotting area. const approximateStep = ((maximum || 1) * 1.08) / 6; const magnitude = 10 ** Math.floor(Math.log10(approximateStep)); const normalizedStep = approximateStep / magnitude; const multiplier = [1, 2, 5, 10].find((value) => value >= normalizedStep); const step = multiplier * magnitude; const limit = Math.ceil(((maximum || 1) * 1.08) / step) * step; const ticks = []; for (let index = 0; index <= Math.round(limit / step); index += 1) { ticks.push(Number((index * step).toPrecision(10))); } return { limit, ticks }; } function renderBarChart(containerId, models, metric) { const percentage = Boolean(performanceMetrics[metric]); const maximum = Math.max(...models.map((model) => model[metric])); const limit = percentage ? 100 : axisScale(maximum).limit; const axisEnd = percentage ? '100%' : formatMetric(limit, metric); getElement(containerId).innerHTML = models .map( (model) => `
${escapeHtml(displayModelName(model))} ${formatMetric(model[metric], metric)}
`, ) .join('') + `
0${axisEnd}
`; } function renderComparisonChart(containerId, models, metric) { const quality = containerId === 'quality-chart'; const type = getElement(quality ? 'quality-chart-type' : 'cost-chart-type').value; const percentage = Boolean(performanceMetrics[metric]); const limit = percentage ? 100 : axisScale(Math.max(...models.map((model) => model[metric]))).limit; if (type === 'bar') { renderBarChart(containerId, models, metric); } else { PaddyLiteXCharts.renderComparison(containerId, models, metric, { type, study: currentStudy, limit, format: (value) => formatMetric(value, metric), }); } if (quality) { getElement('quality-chart-note').textContent = ['radar', 'heatmap'].includes(type) ? 'All four detection metrics (%) · shared 0–100% scale. The metric selector still controls the scatter plot.' : 'Selected detection metric (%) · higher is better · scale starts at zero.'; } } function renderScatterPlot(models, performanceMetric, complexityMetric) { const performance = performanceMetrics[performanceMetric]; const complexity = complexityMetrics[complexityMetric]; const width = 1000; const height = 340; const margin = { left: 75, right: 40, top: 30, bottom: 65 }; const plotWidth = width - margin.left - margin.right; const plotHeight = height - margin.top - margin.bottom; const horizontalScale = axisScale( Math.max(...models.map((model) => model[complexityMetric] / complexity.scale)), ); const x = (value) => margin.left + (value / horizontalScale.limit) * plotWidth; const y = (value) => height - margin.bottom - (value / 100) * plotHeight; const horizontalGrid = [0, 20, 40, 60, 80, 100] .map( (value) => ` ${value} `, ) .join(''); const horizontalTicks = horizontalScale.ticks .map( (value) => ` ${value} `, ) .join(''); const points = models .map((model, index) => { const pointX = x(model[complexityMetric] / complexity.scale); const pointY = y(model[performanceMetric]); const tooltip = `${displayModelName(model)}: ${complexity.label} ${formatMetric(model[complexityMetric], complexityMetric)}; ${performance.label} ${formatMetric(model[performanceMetric], performanceMetric)}`; const label = currentStudy === 'main' ? ` ${model.role === 'Proposed' ? 'Proposed' : escapeHtml(displayModelName(model))} ` : ''; return ` ${escapeHtml(tooltip)} ${label} `; }) .join(''); getElement('scatter').innerHTML = ` ${horizontalGrid} ${horizontalTicks} ${complexity.axisLabel} ${performance.label} (%) ${points} `; getElement('scatter-metrics').textContent = `${complexity.label} vs. ${performance.label}`; getElement('legend').innerHTML = models .map( (model) => ` ${escapeHtml(displayModelName(model))} `, ) .join(''); } function renderBenchmarks() { if (!benchmarkData) return; const models = benchmarkData[currentStudy]; const performanceMetric = getElement('metric').value; const complexityMetric = getElement('cost-metric').value; renderComparisonChart('quality-chart', models, performanceMetric); renderComparisonChart('cost-chart', models, complexityMetric); renderScatterPlot(models, performanceMetric, complexityMetric); renderClassChart(); getElement('metrics-table').innerHTML = models .map( (model) => ` ${escapeHtml(displayModelName(model))} ${model.precision.toFixed(2)}% ${model.recall.toFixed(2)}% ${model.map50.toFixed(2)}% ${model.map5095.toFixed(2)}% ${formatNumber(model.parameters)} ${model.gflops.toFixed(1)} `, ) .join(''); } // Class metrics come from a separate file so checkpoint paths never need editing. function validateClassData(data) { if (data?.schema_version !== 1 || data.unit !== 'percent' || !Array.isArray(data.classes)) { throw new Error('The class results file has an unsupported format.'); } const ids = new Set(); for (const row of data.classes) { if ( !Number.isInteger(row.id) || row.id < 0 || row.id >= paddyClassNames.length || ids.has(row.id) || typeof row.name !== 'string' || !row.name.trim() || !row.models || typeof row.models !== 'object' || Array.isArray(row.models) ) { throw new Error('Check the class IDs, names and model results in the class results file.'); } ids.add(row.id); for (const scores of Object.values(row.models)) { if (!scores || typeof scores !== 'object' || Array.isArray(scores)) { throw new Error('Each model must have a class metric object.'); } for (const metric of Object.keys(classMetricLabels)) { const value = scores[metric]; if ( value != null && (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 100) ) { throw new Error('Class metrics must be percentages between 0 and 100, or null.'); } } } } return data; } function classScore(row, modelId, metric) { const value = row.models[modelId]?.[metric]; return typeof value === 'number' && Number.isFinite(value) ? value : null; } // Ablation studies may have class results for only some of their models. function classChartModels() { if (!benchmarkData) return []; if (currentStudy === 'main') return benchmarkData.main; const rows = classRows(); return benchmarkData.ablation.filter((model) => rows.some((row) => Object.keys(classMetricLabels).some((metric) => classScore(row, model.id, metric) !== null), ), ); } function classModelCode(model, index) { if (model.role === 'Proposed') return 'P'; return currentStudy === 'main' ? `B${index + 1}` : `A${index + 1}`; } function renderClassChart() { if (!benchmarkData) return; const panel = getElement('class-panel'); panel.hidden = false; const models = classChartModels(); const mainStudy = currentStudy === 'main'; getElement('class-heading').textContent = mainStudy ? 'Every class, three perspectives.' : 'Class results across available ablations.'; getElement('class-heading').nextElementSibling.textContent = mainStudy ? 'Compare your two baselines and final proposed model for each class.' : 'Compare the ablation models with supplied class results and the final proposed model.'; const metric = getElement('class-metric').value; const label = classMetricLabels[metric]; const rows = classRows(); const proposed = models.find((model) => model.role === 'Proposed'); const order = getElement('class-order').value; if (order !== 'dataset' && proposed) { rows.sort((a, b) => { const first = classScore(a, proposed.id, metric); const second = classScore(b, proposed.id, metric); if (first == null) return second == null ? a.id - b.id : 1; if (second == null) return -1; return (order === 'lowest' ? first - second : second - first) || a.id - b.id; }); } getElement('class-legend').innerHTML = models .map( (model) => ` ${escapeHtml(model.role)} · ${escapeHtml(displayModelName(model))} `, ) .join(''); const hasValues = rows.some((row) => models.some((model) => Object.keys(classMetricLabels).some((key) => classScore(row, model.id, key) !== null), ), ); getElement('download-class-csv').disabled = !hasValues; const split = classData?.context?.split || 'test'; getElement('class-context').textContent = rows.length ? `${split} set · ${rows.length} classes · ${label} (%)` : ''; getElement('class-chart').innerHTML = ` ${rows .map( (row) => `
${escapeHtml(row.name)}Class ${row.id}
${models .map((model, index) => { const value = classScore(row, model.id, metric); const text = value === null ? 'N/A' : `${value.toFixed(2)}%`; const caption = `${row.name} · ${displayModelName(model)} · ${label}: ${text}`; return ` `; }) .join('')}
`, ) .join('')}`; const missingModels = currentStudy === 'ablation' ? benchmarkData.ablation.length - models.length : 0; const coverageNote = missingModels > 0 ? ` Class results are available for ${models.length} of ${benchmarkData.ablation.length} ablation models. Models without supplied class results are omitted.` : ''; getElement('class-note').textContent = classDataError || (hasValues ? `${split} set · percent · higher is better · 0–100% scale. N/A means no evaluated value was supplied. AP is reported per class; mAP averages across classes.${coverageNote}` : 'All nine disease classes are shown. N/A means their evaluation scores have not been supplied yet.'); const chartType = getElement('class-chart-type').value; if (chartType !== 'bar' && hasValues) { PaddyLiteXCharts.renderClasses(models, rows, metric, { type: chartType, study: currentStudy, label, }); if (chartType === 'radar') { getElement('class-note').textContent += ' Radar axes stay in dataset class order; model names toggle the series.'; } } } async function loadClassMetrics() { try { const url = new URL(paddyClassResultsUrl); url.searchParams.set('v', 'baseline1-charts-20261013'); const response = await fetch(url, { cache: 'no-store' }); if (!response.ok) { throw new Error(`per_class.json could not load (HTTP ${response.status}). Upload it beside app.js.`); } const raw = await response.text(); let data; try { data = JSON.parse(raw); } catch { throw new Error(raw.trimStart().startsWith('<') ? 'per_class.json returned an HTML page. Check its upload location beside app.js.' : 'per_class.json is not valid JSON. Replace it with the supplied results file.'); } classData = validateClassData(data); classDataError = ''; } catch (error) { classDataError = `Class results could not load. ${error.message}`; } renderClassChart(); } function downloadClassMetrics() { if (!classData?.classes.length || !benchmarkData) return; const rows = [ [ 'Class ID', 'Class', 'Model', 'Role', 'Precision (%)', 'Recall (%)', 'AP@0.5 (%)', 'AP@0.5:0.95 (%)', ], ]; for (const row of classRows()) { for (const model of classChartModels()) { rows.push([ row.id, row.name, displayModelName(model), model.role, ...['precision', 'recall', 'map50', 'map5095'].map( (metric) => classScore(row, model.id, metric) ?? '', ), ]); } } const content = rows .map((row) => row.map((value) => `"${String(value).replaceAll('"', '""')}"`).join(',')) .join('\r\n'); const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' })); const link = document.createElement('a'); link.href = url; link.download = `paddylitex_${currentStudy}_per_class.csv`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function renderSummaryCards() { const context = benchmarkData.context; const proposed = benchmarkData.main.find((model) => model.role === 'Proposed'); const baseline = benchmarkData.main.find((model) => model.id === 'yolov8n_densenet121'); getElement('test-images').textContent = formatNumber(context.images); getElement('test-objects').textContent = formatNumber(context.objects); getElement('test-classes').textContent = formatNumber(context.classes); if (proposed) { getElement('proposed-map5095').innerHTML = `${proposed.map5095.toFixed(2)}%`; getElement('proposed-map50').innerHTML = `${proposed.map50.toFixed(2)}%`; } if (proposed && baseline) { const improvements = [ { metric: 'map5095', valueId: 'map-improvement', changeId: 'map5095-change' }, { metric: 'map50', valueId: 'map50-improvement', changeId: 'map50-change' }, ]; for (const { metric, valueId, changeId } of improvements) { const scoreGain = proposed[metric] - baseline[metric]; getElement(valueId).textContent = `${scoreGain >= 0 ? '+' : ''}${scoreGain.toFixed(2)}%`; // C2f x 1 result updates: show signed score differences. getElement(valueId).style.color = scoreGain < 0 ? '#b5475a' : ''; const scoreLabel = getElement(valueId).nextElementSibling; if (scoreLabel) { scoreLabel.textContent = `${performanceMetrics[metric].label} score difference`; } getElement(valueId).title = `Score difference: ${scoreGain.toFixed(2)} percentage points (${proposed[metric].toFixed(2)}% − ${baseline[metric].toFixed(2)}%).`; getElement(changeId).textContent = `${baseline[metric].toFixed(2)}% → ${proposed[metric].toFixed(2)}%`; } getElement('parameter-reduction').textContent = `${(((baseline.parameters - proposed.parameters) / baseline.parameters) * 100).toFixed(2)}% fewer`; getElement('flops-reduction').textContent = `${(((baseline.gflops - proposed.gflops) / baseline.gflops) * 100).toFixed(2)}% fewer`; } } function downloadBenchmarks() { if (!benchmarkData) return; const rows = [ [ 'Model', 'Precision (%)', 'Recall (%)', 'mAP@0.5 (%)', 'mAP@0.5:0.95 (%)', 'Parameters', 'GFLOPs', ], ...benchmarkData[currentStudy].map((model) => [ displayModelName(model), model.precision, model.recall, model.map50, model.map5095, model.parameters, model.gflops, ]), ]; const content = rows .map((row) => row.map((value) => `"${String(value).replaceAll('"', '""')}"`).join(',')) .join('\r\n'); const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' })); const link = document.createElement('a'); link.href = url; link.download = `paddylitex_${currentStudy}_benchmarks.csv`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } // Event listeners function markSettingsChanged() { if (latestResults) getElement('message').textContent = 'Settings changed. Compare again to update predictions.'; } document.querySelectorAll('[data-view]').forEach((button) => { button.addEventListener('click', () => showView(button.dataset.view)); }); document.querySelectorAll('[data-scope]').forEach((button) => { button.addEventListener('click', () => { currentStudy = button.dataset.scope; document .querySelectorAll('[data-scope]') .forEach((item) => item.classList.toggle('selected', item === button)); renderBenchmarks(); }); }); getElement('metric').addEventListener('change', renderBenchmarks); getElement('quality-chart-type').addEventListener('change', renderBenchmarks); getElement('cost-chart-type').addEventListener('change', renderBenchmarks); getElement('class-chart-type').addEventListener('change', renderClassChart); getElement('cost-metric').addEventListener('change', renderBenchmarks); getElement('download-csv').addEventListener('click', downloadBenchmarks); getElement('class-metric').addEventListener('change', renderClassChart); getElement('class-order').addEventListener('change', renderClassChart); getElement('download-class-csv').addEventListener('click', downloadClassMetrics); getElement('file').addEventListener('change', (event) => selectFile(event.target.files[0])); getElement('clear').addEventListener('click', clearFile); getElement('compare').addEventListener('click', compareModels); getElement('image-size').addEventListener('change', markSettingsChanged); getElement('confidence').addEventListener('input', () => { getElement('confidence-value').textContent = Number(getElement('confidence').value).toFixed(2); markSettingsChanged(); }); getElement('dropzone').addEventListener('keydown', (event) => { if ((event.key === 'Enter' || event.key === ' ') && !comparisonRunning) { event.preventDefault(); getElement('file').click(); } }); ['dragenter', 'dragover'].forEach((eventName) => { getElement('dropzone').addEventListener(eventName, (event) => { event.preventDefault(); if (!comparisonRunning) getElement('dropzone').classList.add('dragging'); }); }); ['dragleave', 'drop'].forEach((eventName) => { getElement('dropzone').addEventListener(eventName, (event) => { event.preventDefault(); getElement('dropzone').classList.remove('dragging'); }); }); getElement('dropzone').addEventListener('drop', (event) => { if (!comparisonRunning) selectFile(event.dataTransfer.files[0]); }); async function initialize() { try { benchmarkData = await readResponse(await PaddyLiteXBrowser.fetch('/api/benchmarks')); const status = await readResponse(await PaddyLiteXBrowser.fetch('/api/status')); const available = status.models.filter((model) => model.checkpoint_present).length; const statusBadge = getElement('service-status'); // Hide the installed-checkpoint badge without claiming inference was verified. statusBadge.hidden = status.inference_enabled && available === benchmarkData.main.length; statusBadge.textContent = !status.inference_enabled ? 'Inference paused' : `${available}/${benchmarkData.main.length} checkpoints installed`; getElement('model-list').innerHTML = benchmarkData.main .map( (model) => `
${escapeHtml(displayModelName(model))} ${escapeHtml(model.role)} · ${(model.parameters / 1000000).toFixed(2)} M parameters
${status.models.find((item) => item.id === model.id)?.checkpoint_present ? 'Checkpoint installed' : 'Awaiting checkpoint'}
`, ) .join(''); renderSummaryCards(); showEmptyResults(); renderBenchmarks(); updateControls(); } catch (error) { getElement('service-status').hidden = false; getElement('service-status').textContent = 'Service unavailable'; getElement('message').textContent = error.message; } } // Presentation controls. The illustration is separate from model predictions. function initializePresentation() { const hero = getElement('studio-hero'); const replayButton = getElement('replay-intro'); const motionButton = getElement('toggle-motion'); const preference = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : { matches: false }; let motionPaused = false; function updateMotionControls() { const reduced = preference.matches; hero.classList.toggle('motion-paused', motionPaused || reduced); replayButton.disabled = reduced; motionButton.disabled = reduced; motionButton.textContent = reduced ? 'Motion reduced' : motionPaused ? 'Play motion' : 'Pause motion'; motionButton.setAttribute('aria-pressed', String(motionPaused || reduced)); } motionButton.addEventListener('click', () => { motionPaused = !motionPaused; updateMotionControls(); }); replayButton.addEventListener('click', () => { if (preference.matches) return; motionPaused = false; updateMotionControls(); hero.classList.remove('intro-playing'); // Restart the entrance after the browser has painted the reset state. requestAnimationFrame(() => requestAnimationFrame(() => hero.classList.add('intro-playing'))); }); getElement('start-comparison').addEventListener('click', () => { getElement('studio-input').scrollIntoView({ behavior: preference.matches ? 'auto' : 'smooth', block: 'start', }); getElement('dropzone').focus({ preventScroll: true }); }); if (typeof IntersectionObserver === 'function') { const observer = new IntersectionObserver(([entry]) => { hero.classList.toggle('motion-offscreen', !entry.isIntersecting); }); observer.observe(hero); } preference.addEventListener?.('change', updateMotionControls); updateMotionControls(); } initializePresentation(); initialize(); loadClassMetrics();