|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>SAM3 Object Detection Browser</title> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; |
|
|
line-height: 1.5; |
|
|
color: #222; |
|
|
background: #fff; |
|
|
font-size: 15px; |
|
|
} |
|
|
|
|
|
.container { |
|
|
max-width: 1400px; |
|
|
margin: 0 auto; |
|
|
padding: 40px 20px; |
|
|
} |
|
|
|
|
|
header { |
|
|
border-bottom: 1px solid #ddd; |
|
|
padding-bottom: 20px; |
|
|
margin-bottom: 40px; |
|
|
} |
|
|
|
|
|
h1 { |
|
|
color: #222; |
|
|
font-weight: 400; |
|
|
font-size: 28px; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.subtitle { |
|
|
color: #666; |
|
|
font-size: 15px; |
|
|
font-weight: 300; |
|
|
margin-bottom: 12px; |
|
|
} |
|
|
|
|
|
.header-links { |
|
|
font-size: 13px; |
|
|
color: #666; |
|
|
} |
|
|
|
|
|
.header-links a { |
|
|
color: #222; |
|
|
text-decoration: none; |
|
|
border-bottom: 1px solid #222; |
|
|
} |
|
|
|
|
|
.header-links a:hover { |
|
|
border-bottom-color: #666; |
|
|
} |
|
|
|
|
|
.example-datasets { |
|
|
margin-top: 8px; |
|
|
font-size: 12px; |
|
|
color: #666; |
|
|
} |
|
|
|
|
|
.example-datasets a { |
|
|
color: #666; |
|
|
text-decoration: none; |
|
|
border-bottom: 1px dotted #999; |
|
|
cursor: pointer; |
|
|
margin-right: 12px; |
|
|
} |
|
|
|
|
|
.example-datasets a:hover { |
|
|
color: #222; |
|
|
border-bottom-color: #222; |
|
|
} |
|
|
|
|
|
.controls { |
|
|
border: 1px solid #ddd; |
|
|
padding: 20px; |
|
|
margin-bottom: 40px; |
|
|
background: #fafafa; |
|
|
} |
|
|
|
|
|
.control-group { |
|
|
margin-bottom: 18px; |
|
|
} |
|
|
|
|
|
.control-group:last-child { |
|
|
margin-bottom: 0; |
|
|
} |
|
|
|
|
|
label { |
|
|
display: block; |
|
|
font-weight: 400; |
|
|
margin-bottom: 6px; |
|
|
color: #444; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
input[type="text"], |
|
|
select { |
|
|
width: 100%; |
|
|
padding: 8px; |
|
|
border: 1px solid #ccc; |
|
|
border-radius: 2px; |
|
|
font-size: 14px; |
|
|
background: white; |
|
|
} |
|
|
|
|
|
input[type="text"]:focus, |
|
|
select:focus { |
|
|
outline: none; |
|
|
border-color: #888; |
|
|
} |
|
|
|
|
|
input[type="range"] { |
|
|
width: 100%; |
|
|
margin-right: 10px; |
|
|
} |
|
|
|
|
|
.slider-container { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 15px; |
|
|
} |
|
|
|
|
|
.slider-value { |
|
|
min-width: 50px; |
|
|
font-weight: 400; |
|
|
color: #222; |
|
|
} |
|
|
|
|
|
.checkbox-container { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 10px; |
|
|
} |
|
|
|
|
|
input[type="checkbox"] { |
|
|
width: 18px; |
|
|
height: 18px; |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
.stats { |
|
|
border-top: 1px solid #ddd; |
|
|
border-bottom: 1px solid #ddd; |
|
|
padding: 25px 0; |
|
|
margin-bottom: 40px; |
|
|
} |
|
|
|
|
|
.stats-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|
|
gap: 30px; |
|
|
} |
|
|
|
|
|
.stat-item { |
|
|
text-align: center; |
|
|
padding: 0; |
|
|
} |
|
|
|
|
|
.stat-value { |
|
|
font-size: 36px; |
|
|
font-weight: 300; |
|
|
color: #222; |
|
|
margin-bottom: 4px; |
|
|
letter-spacing: -0.5px; |
|
|
} |
|
|
|
|
|
.stat-label { |
|
|
font-size: 13px; |
|
|
color: #666; |
|
|
font-weight: 400; |
|
|
text-transform: uppercase; |
|
|
letter-spacing: 0.5px; |
|
|
} |
|
|
|
|
|
.loading { |
|
|
text-align: center; |
|
|
padding: 40px; |
|
|
font-size: 14px; |
|
|
color: #666; |
|
|
} |
|
|
|
|
|
.error { |
|
|
background: #fff; |
|
|
border: 1px solid #d00; |
|
|
padding: 15px; |
|
|
margin: 20px 0; |
|
|
color: #d00; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
.image-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); |
|
|
gap: 40px; |
|
|
margin-bottom: 40px; |
|
|
} |
|
|
|
|
|
.image-card { |
|
|
background: white; |
|
|
border: 1px solid #ddd; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.image-card:hover { |
|
|
border-color: #999; |
|
|
} |
|
|
|
|
|
.image-container { |
|
|
position: relative; |
|
|
width: 100%; |
|
|
background: #000; |
|
|
} |
|
|
|
|
|
.image-container img { |
|
|
width: 100%; |
|
|
height: auto; |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.image-container canvas { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
pointer-events: none; |
|
|
z-index: 10; |
|
|
} |
|
|
|
|
|
.image-info { |
|
|
padding: 12px; |
|
|
border-top: 1px solid #eee; |
|
|
} |
|
|
|
|
|
.image-index { |
|
|
font-size: 11px; |
|
|
color: #999; |
|
|
margin-bottom: 6px; |
|
|
text-transform: uppercase; |
|
|
letter-spacing: 0.5px; |
|
|
} |
|
|
|
|
|
.detections-count { |
|
|
font-weight: 400; |
|
|
color: #222; |
|
|
margin-bottom: 8px; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
.detections-list { |
|
|
font-size: 13px; |
|
|
color: #666; |
|
|
} |
|
|
|
|
|
.detection-item { |
|
|
padding: 3px 0; |
|
|
} |
|
|
|
|
|
.confidence-high { |
|
|
color: #27ae60; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.confidence-medium { |
|
|
color: #f39c12; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.confidence-low { |
|
|
color: #e74c3c; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.pagination { |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
gap: 20px; |
|
|
margin: 40px 0; |
|
|
padding-top: 20px; |
|
|
border-top: 1px solid #ddd; |
|
|
} |
|
|
|
|
|
.pagination button { |
|
|
padding: 8px 16px; |
|
|
border: 1px solid #666; |
|
|
background: white; |
|
|
color: #222; |
|
|
cursor: pointer; |
|
|
font-size: 13px; |
|
|
transition: all 0.2s; |
|
|
} |
|
|
|
|
|
.pagination button:hover:not(:disabled) { |
|
|
background: #222; |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.pagination button:disabled { |
|
|
border-color: #ddd; |
|
|
color: #ccc; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
|
|
|
.pagination span { |
|
|
color: #666; |
|
|
font-size: 13px; |
|
|
} |
|
|
|
|
|
.load-button { |
|
|
padding: 10px 20px; |
|
|
border: 1px solid #666; |
|
|
background: white; |
|
|
color: #222; |
|
|
cursor: pointer; |
|
|
font-size: 14px; |
|
|
font-weight: 400; |
|
|
transition: all 0.2s; |
|
|
width: 100%; |
|
|
margin-top: 12px; |
|
|
} |
|
|
|
|
|
.load-button:hover { |
|
|
background: #222; |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.load-button:disabled { |
|
|
border-color: #ddd; |
|
|
color: #ccc; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<header> |
|
|
<h1>SAM3 Object Detection Browser</h1> |
|
|
<p class="subtitle">Browse and explore object detection results from Meta's SAM3 model</p> |
|
|
<p class="header-links">Create your own detection dataset with the <a href="https://huggingface.co/datasets/uv-scripts/sam3" target="_blank">SAM3 detection script</a></p> |
|
|
</header> |
|
|
|
|
|
<div class="controls"> |
|
|
<div class="control-group"> |
|
|
<label for="dataset-id">Dataset ID:</label> |
|
|
<input type="text" id="dataset-id" value="davanstrien/newspapers-image-predictions" placeholder="username/dataset-name"> |
|
|
<div class="example-datasets"> |
|
|
Examples: |
|
|
<a href="#" data-dataset="davanstrien/newspapers-image-predictions">photographs</a> |
|
|
<a href="#" data-dataset="davanstrien/newspapers-illustration-predictions">illustrations</a> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<label for="split">Split:</label> |
|
|
<select id="split"> |
|
|
<option value="train">train</option> |
|
|
<option value="validation">validation</option> |
|
|
<option value="test">test</option> |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<label for="confidence-threshold">Confidence Threshold: <span id="confidence-value" class="slider-value">0.50</span></label> |
|
|
<div class="slider-container"> |
|
|
<input type="range" id="confidence-threshold" min="0" max="1" step="0.05" value="0.5"> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<div class="checkbox-container"> |
|
|
<input type="checkbox" id="only-detections"> |
|
|
<label for="only-detections" style="margin-bottom: 0;">Show only images with detections</label> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<button class="load-button" id="load-dataset">Load Dataset</button> |
|
|
</div> |
|
|
|
|
|
<div id="stats-container"></div> |
|
|
<div id="loading" class="loading" style="display: none;">Loading dataset...</div> |
|
|
<div id="error" class="error" style="display: none;"></div> |
|
|
<div id="image-grid" class="image-grid"></div> |
|
|
<div id="pagination" class="pagination" style="display: none;"></div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
|
|
|
let dataset = []; |
|
|
let filteredDataset = []; |
|
|
let currentPage = 0; |
|
|
const itemsPerPage = 12; |
|
|
let confidenceThreshold = 0.5; |
|
|
let showOnlyDetections = false; |
|
|
|
|
|
|
|
|
const datasetIdInput = document.getElementById('dataset-id'); |
|
|
const splitSelect = document.getElementById('split'); |
|
|
const confidenceSlider = document.getElementById('confidence-threshold'); |
|
|
const confidenceValue = document.getElementById('confidence-value'); |
|
|
const onlyDetectionsCheckbox = document.getElementById('only-detections'); |
|
|
const loadButton = document.getElementById('load-dataset'); |
|
|
const statsContainer = document.getElementById('stats-container'); |
|
|
const loadingDiv = document.getElementById('loading'); |
|
|
const errorDiv = document.getElementById('error'); |
|
|
const imageGrid = document.getElementById('image-grid'); |
|
|
const paginationDiv = document.getElementById('pagination'); |
|
|
|
|
|
|
|
|
showOnlyDetections = onlyDetectionsCheckbox.checked; |
|
|
|
|
|
|
|
|
confidenceSlider.addEventListener('input', (e) => { |
|
|
confidenceThreshold = parseFloat(e.target.value); |
|
|
confidenceValue.textContent = confidenceThreshold.toFixed(2); |
|
|
if (dataset.length > 0) { |
|
|
filterAndRender(); |
|
|
} |
|
|
}); |
|
|
|
|
|
onlyDetectionsCheckbox.addEventListener('change', (e) => { |
|
|
showOnlyDetections = e.target.checked; |
|
|
if (dataset.length > 0) { |
|
|
filterAndRender(); |
|
|
} |
|
|
}); |
|
|
|
|
|
loadButton.addEventListener('click', loadDataset); |
|
|
|
|
|
|
|
|
document.querySelectorAll('.example-datasets a').forEach(link => { |
|
|
link.addEventListener('click', (e) => { |
|
|
e.preventDefault(); |
|
|
const datasetId = e.target.getAttribute('data-dataset'); |
|
|
datasetIdInput.value = datasetId; |
|
|
loadDataset(); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
async function loadDataset() { |
|
|
const datasetId = datasetIdInput.value.trim(); |
|
|
const split = splitSelect.value; |
|
|
|
|
|
if (!datasetId) { |
|
|
showError('Please enter a dataset ID'); |
|
|
return; |
|
|
} |
|
|
|
|
|
loadingDiv.style.display = 'block'; |
|
|
errorDiv.style.display = 'none'; |
|
|
statsContainer.innerHTML = ''; |
|
|
imageGrid.innerHTML = ''; |
|
|
paginationDiv.style.display = 'none'; |
|
|
loadButton.disabled = true; |
|
|
|
|
|
dataset = []; |
|
|
let isLoadingComplete = false; |
|
|
|
|
|
try { |
|
|
|
|
|
let offset = 0; |
|
|
const batchSize = 50; |
|
|
let hasMore = true; |
|
|
let isFirstBatch = true; |
|
|
|
|
|
while (hasMore) { |
|
|
const url = `https://datasets-server.huggingface.co/rows?dataset=${encodeURIComponent(datasetId)}&config=default&split=${split}&offset=${offset}&length=${batchSize}`; |
|
|
const response = await fetch(url); |
|
|
|
|
|
if (!response.ok) { |
|
|
if (offset === 0) { |
|
|
throw new Error(`Failed to load dataset. Check dataset ID and split name.`); |
|
|
} |
|
|
break; |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (!data.rows || data.rows.length === 0) { |
|
|
hasMore = false; |
|
|
break; |
|
|
} |
|
|
|
|
|
|
|
|
const newRows = data.rows.map(item => ({ |
|
|
index: offset + item.row_idx, |
|
|
image: item.row.image, |
|
|
objects: item.row.objects || { bbox: [], category: [], score: [] }, |
|
|
...item.row |
|
|
})); |
|
|
|
|
|
dataset = dataset.concat(newRows); |
|
|
offset += data.rows.length; |
|
|
|
|
|
|
|
|
if (isFirstBatch) { |
|
|
filterAndRender(); |
|
|
loadingDiv.textContent = `Loading more... (${dataset.length} rows loaded)`; |
|
|
isFirstBatch = false; |
|
|
} else { |
|
|
|
|
|
filterDataset(); |
|
|
renderStats(); |
|
|
|
|
|
if (currentPage === 0) { |
|
|
renderPage(); |
|
|
renderPagination(); |
|
|
} |
|
|
loadingDiv.textContent = `Loading more... (${dataset.length} rows loaded)`; |
|
|
} |
|
|
|
|
|
|
|
|
if (data.rows.length < batchSize) { |
|
|
hasMore = false; |
|
|
} |
|
|
|
|
|
|
|
|
if (dataset.length >= 10000) { |
|
|
console.log('Loaded 10,000 rows, stopping to prevent performance issues'); |
|
|
hasMore = false; |
|
|
} |
|
|
} |
|
|
|
|
|
if (dataset.length === 0) { |
|
|
throw new Error('No data found in dataset'); |
|
|
} |
|
|
|
|
|
|
|
|
isLoadingComplete = true; |
|
|
loadingDiv.style.display = 'none'; |
|
|
filterAndRender(); |
|
|
|
|
|
} catch (error) { |
|
|
loadingDiv.style.display = 'none'; |
|
|
showError(`Failed to load dataset: ${error.message}`); |
|
|
} finally { |
|
|
loadButton.disabled = false; |
|
|
if (isLoadingComplete) { |
|
|
loadingDiv.textContent = 'Loading dataset...'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function filterDataset() { |
|
|
filteredDataset = dataset.filter(item => { |
|
|
const objects = item.objects || { bbox: [], category: [], score: [] }; |
|
|
|
|
|
|
|
|
const validDetections = objects.score.filter(score => score >= confidenceThreshold); |
|
|
|
|
|
|
|
|
if (showOnlyDetections && validDetections.length === 0) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
return true; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function filterAndRender() { |
|
|
filterDataset(); |
|
|
currentPage = 0; |
|
|
renderStats(); |
|
|
renderPage(); |
|
|
renderPagination(); |
|
|
} |
|
|
|
|
|
|
|
|
function renderStats() { |
|
|
|
|
|
const totalImages = filteredDataset.length; |
|
|
const totalDetections = filteredDataset.reduce((sum, item) => { |
|
|
const objects = item.objects || { bbox: [], category: [], score: [] }; |
|
|
return sum + objects.score.filter(score => score >= confidenceThreshold).length; |
|
|
}, 0); |
|
|
|
|
|
const imagesWithDetections = filteredDataset.filter(item => { |
|
|
const objects = item.objects || { bbox: [], category: [], score: [] }; |
|
|
return objects.score.some(score => score >= confidenceThreshold); |
|
|
}).length; |
|
|
|
|
|
const avgDetections = totalImages > 0 ? (totalDetections / totalImages).toFixed(2) : 0; |
|
|
|
|
|
statsContainer.innerHTML = ` |
|
|
<div class="stats"> |
|
|
<div class="stats-grid"> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-value">${filteredDataset.length}</div> |
|
|
<div class="stat-label">Filtered Images</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-value">${imagesWithDetections}</div> |
|
|
<div class="stat-label">Images with Detections</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-value">${totalDetections}</div> |
|
|
<div class="stat-label">Total Detections</div> |
|
|
</div> |
|
|
<div class="stat-item"> |
|
|
<div class="stat-value">${avgDetections}</div> |
|
|
<div class="stat-label">Avg per Image</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
function renderPage() { |
|
|
const start = currentPage * itemsPerPage; |
|
|
const end = start + itemsPerPage; |
|
|
const pageItems = filteredDataset.slice(start, end); |
|
|
|
|
|
imageGrid.innerHTML = pageItems.map(item => { |
|
|
const objects = item.objects || { bbox: [], category: [], score: [] }; |
|
|
const validDetections = objects.score |
|
|
.map((score, idx) => ({ score, idx })) |
|
|
.filter(({ score }) => score >= confidenceThreshold); |
|
|
|
|
|
|
|
|
const imageUrl = typeof item.image === 'object' ? item.image?.src : item.image; |
|
|
|
|
|
return ` |
|
|
<div class="image-card"> |
|
|
<div class="image-container" id="container-${item.index}"> |
|
|
<img src="${imageUrl}" alt="Image ${item.index}" crossorigin="anonymous" onload="drawBoundingBoxes(${item.index})"> |
|
|
<canvas id="canvas-${item.index}"></canvas> |
|
|
</div> |
|
|
<div class="image-info"> |
|
|
<div class="image-index">Image #${item.index}</div> |
|
|
<div class="detections-count">${validDetections.length} detection(s)</div> |
|
|
<div class="detections-list"> |
|
|
${validDetections.map(({ score, idx }) => { |
|
|
const confidenceClass = score >= 0.7 ? 'confidence-high' : score >= 0.4 ? 'confidence-medium' : 'confidence-low'; |
|
|
const category = objects.category && objects.category[idx] !== undefined ? objects.category[idx] : 0; |
|
|
return ` |
|
|
<div class="detection-item"> |
|
|
<span class="${confidenceClass}">${(score * 100).toFixed(1)}%</span> |
|
|
confidence |
|
|
</div> |
|
|
`; |
|
|
}).join('')} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
} |
|
|
|
|
|
|
|
|
window.drawBoundingBoxes = function(itemIndex) { |
|
|
const item = dataset.find(d => d.index === itemIndex); |
|
|
if (!item) return; |
|
|
|
|
|
const container = document.getElementById(`container-${itemIndex}`); |
|
|
const canvas = document.getElementById(`canvas-${itemIndex}`); |
|
|
const img = container.querySelector('img'); |
|
|
|
|
|
if (!canvas || !img) return; |
|
|
|
|
|
|
|
|
canvas.width = img.naturalWidth; |
|
|
canvas.height = img.naturalHeight; |
|
|
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
|
|
|
const objects = item.objects || { bbox: [], category: [], score: [] }; |
|
|
|
|
|
|
|
|
const validBoxes = objects.bbox.filter((_, idx) => objects.score[idx] >= confidenceThreshold); |
|
|
if (validBoxes.length > 0) { |
|
|
console.log(`Drawing ${validBoxes.length} boxes for image #${itemIndex}`); |
|
|
} |
|
|
|
|
|
|
|
|
objects.bbox.forEach((bbox, idx) => { |
|
|
const score = objects.score[idx]; |
|
|
if (score < confidenceThreshold) return; |
|
|
|
|
|
const [x, y, width, height] = bbox; |
|
|
|
|
|
|
|
|
console.log(` Box ${idx}: [${x}, ${y}, ${width}, ${height}] score: ${score}`); |
|
|
|
|
|
|
|
|
let color; |
|
|
if (score >= 0.7) { |
|
|
color = '#27ae60'; |
|
|
} else if (score >= 0.4) { |
|
|
color = '#f39c12'; |
|
|
} else { |
|
|
color = '#e74c3c'; |
|
|
} |
|
|
|
|
|
|
|
|
ctx.strokeStyle = color; |
|
|
ctx.lineWidth = 3; |
|
|
ctx.strokeRect(x, y, width, height); |
|
|
|
|
|
console.log(` Drew box at [${x}, ${y}] size [${width}, ${height}] in ${color}`); |
|
|
|
|
|
|
|
|
const label = `${(score * 100).toFixed(1)}%`; |
|
|
ctx.font = 'bold 16px Arial'; |
|
|
const textWidth = ctx.measureText(label).width; |
|
|
const textHeight = 20; |
|
|
|
|
|
ctx.fillStyle = color; |
|
|
ctx.fillRect(x, y - textHeight - 4, textWidth + 10, textHeight + 4); |
|
|
|
|
|
|
|
|
ctx.fillStyle = 'white'; |
|
|
ctx.fillText(label, x + 5, y - 8); |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
function renderPagination() { |
|
|
const totalPages = Math.ceil(filteredDataset.length / itemsPerPage); |
|
|
|
|
|
if (totalPages <= 1) { |
|
|
paginationDiv.style.display = 'none'; |
|
|
return; |
|
|
} |
|
|
|
|
|
paginationDiv.style.display = 'flex'; |
|
|
paginationDiv.innerHTML = ` |
|
|
<button id="prev-btn" ${currentPage === 0 ? 'disabled' : ''}>Previous</button> |
|
|
<span>Page ${currentPage + 1} of ${totalPages}</span> |
|
|
<button id="next-btn" ${currentPage >= totalPages - 1 ? 'disabled' : ''}>Next</button> |
|
|
`; |
|
|
|
|
|
document.getElementById('prev-btn').addEventListener('click', () => { |
|
|
if (currentPage > 0) { |
|
|
currentPage--; |
|
|
renderPage(); |
|
|
renderPagination(); |
|
|
window.scrollTo({ top: 0, behavior: 'smooth' }); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('next-btn').addEventListener('click', () => { |
|
|
if (currentPage < totalPages - 1) { |
|
|
currentPage++; |
|
|
renderPage(); |
|
|
renderPagination(); |
|
|
window.scrollTo({ top: 0, behavior: 'smooth' }); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function showError(message) { |
|
|
errorDiv.textContent = message; |
|
|
errorDiv.style.display = 'block'; |
|
|
} |
|
|
|
|
|
|
|
|
window.addEventListener('load', () => { |
|
|
loadDataset(); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|