geoclip-api / index.html
latterworks's picture
fix: defer map init until container has dimensions
5de35d7 verified
Raw
History Blame Contribute Delete
9.26 kB
<!DOCTYPE html>
<!-- v2 -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoCLIP</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f0f0f;
color: #e8e8e8;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 16px;
}
header {
text-align: center;
margin-bottom: 40px;
}
header h1 {
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #fff;
}
header p {
margin-top: 6px;
font-size: 0.85rem;
color: #666;
}
.card {
background: #181818;
border: 1px solid #272727;
border-radius: 12px;
width: 100%;
max-width: 520px;
overflow: hidden;
}
.drop-zone {
padding: 40px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
border-bottom: 1px solid #272727;
transition: background 0.15s;
position: relative;
}
.drop-zone:hover, .drop-zone.drag-over { background: #1f1f1f; }
.drop-zone input[type=file] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.drop-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #242424;
border: 1px solid #333;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.drop-zone span {
font-size: 0.85rem;
color: #888;
}
.drop-zone span b { color: #ccc; font-weight: 500; }
#preview-wrap {
display: none;
padding: 16px;
border-bottom: 1px solid #272727;
}
#preview-wrap img {
width: 100%;
border-radius: 8px;
max-height: 280px;
object-fit: cover;
}
.actions {
padding: 16px;
border-bottom: 1px solid #272727;
display: none;
}
button {
width: 100%;
padding: 10px;
background: #fff;
color: #000;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover { opacity: 0.85; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
#results { display: none; }
.results-header {
padding: 14px 16px 8px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #555;
display: flex;
justify-content: space-between;
}
.prediction {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px solid #1f1f1f;
}
.prediction:first-of-type { border-top: none; }
.pred-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.rank {
font-size: 0.72rem;
font-weight: 600;
color: #444;
width: 20px;
flex-shrink: 0;
}
.coords {
font-size: 0.88rem;
font-variant-numeric: tabular-nums;
color: #ddd;
flex: 1;
}
.prob-label {
font-size: 0.78rem;
color: #666;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.bar-wrap {
height: 3px;
background: #222;
border-radius: 2px;
overflow: hidden;
margin-left: 32px;
}
.bar {
height: 100%;
background: #fff;
border-radius: 2px;
transition: width 0.4s ease;
}
.prediction:nth-child(1) .bar { background: #fff; }
.prediction:nth-child(2) .bar { background: #aaa; }
.prediction:nth-child(3) .bar { background: #777; }
.prediction:nth-child(4) .bar { background: #555; }
.prediction:nth-child(5) .bar { background: #3a3a3a; }
.map-link {
font-size: 0.75rem;
color: #555;
text-decoration: none;
flex-shrink: 0;
transition: color 0.1s;
}
.map-link:hover { color: #aaa; }
.meta {
padding: 10px 16px;
font-size: 0.72rem;
color: #444;
border-top: 1px solid #1f1f1f;
text-align: right;
}
#map-wrap {
border-bottom: 1px solid #272727;
}
#map {
height: 260px;
background: #111;
}
/* keep Leaflet attribution readable on dark tiles */
.leaflet-control-attribution {
background: rgba(0,0,0,0.5) !important;
color: #555 !important;
font-size: 0.6rem !important;
}
.leaflet-control-attribution a { color: #666 !important; }
#status {
font-size: 0.8rem;
color: #666;
margin-top: 20px;
min-height: 20px;
}
</style>
</head>
<body>
<header>
<h1>GeoCLIP</h1>
<p>Upload an image to predict its location</p>
</header>
<div class="card">
<div class="drop-zone" id="drop-zone">
<input type="file" id="file-input" accept="image/*">
<div class="drop-icon">🌍</div>
<span><b>Click to upload</b> or drag and drop</span>
<span>JPG, PNG, WEBP</span>
</div>
<div id="preview-wrap">
<img id="preview" src="" alt="preview">
</div>
<div class="actions" id="actions">
<button id="predict-btn">Predict location</button>
</div>
<div id="results">
<div class="results-header">
<span>Predictions</span>
<span>Top 5</span>
</div>
<div id="map-wrap">
<div id="map"></div>
</div>
<div id="predictions-list"></div>
<div class="meta" id="meta"></div>
</div>
</div>
<div id="status"></div>
<script>
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const previewWrap = document.getElementById('preview-wrap');
const preview = document.getElementById('preview');
const actions = document.getElementById('actions');
const predictBtn = document.getElementById('predict-btn');
const results = document.getElementById('results');
const predList = document.getElementById('predictions-list');
const meta = document.getElementById('meta');
const status = document.getElementById('status');
let selectedFile = null;
let leafletMap = null;
let heatLayer = null;
function setFile(file) {
if (!file || !file.type.startsWith('image/')) return;
selectedFile = file;
const url = URL.createObjectURL(file);
preview.src = url;
previewWrap.style.display = 'block';
actions.style.display = 'block';
results.style.display = 'none';
predList.innerHTML = '';
status.textContent = '';
}
fileInput.addEventListener('change', e => setFile(e.target.files[0]));
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
setFile(e.dataTransfer.files[0]);
});
predictBtn.addEventListener('click', async () => {
if (!selectedFile) return;
predictBtn.disabled = true;
predictBtn.textContent = 'Predicting…';
status.textContent = '';
results.style.display = 'none';
const form = new FormData();
form.append('file', selectedFile);
try {
const res = await fetch('/predict', { method: 'POST', body: form });
if (!res.ok) throw new Error(`server error ${res.status}`);
const data = await res.json();
const maxProb = data.predictions[0].prob;
predList.innerHTML = data.predictions.map((p, i) => `
<div class="prediction">
<div class="pred-row">
<span class="rank">${p.rank}</span>
<span class="coords">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}</span>
<span class="prob-label">${(p.prob * 100).toFixed(2)}%</span>
<a class="map-link" href="https://www.openstreetmap.org/?mlat=${p.lat}&mlon=${p.lon}#map=10/${p.lat}/${p.lon}" target="_blank" rel="noopener">map ↗</a>
</div>
<div class="bar-wrap">
<div class="bar" style="width:${(p.prob / maxProb * 100).toFixed(1)}%"></div>
</div>
</div>
`).join('');
meta.textContent = `${data.inference_ms}ms`;
results.style.display = 'block';
// defer map ops until the container has non-zero dimensions
const heatMax = data.heatmap[0].prob;
const heatPoints = data.heatmap.map(p => [p.lat, p.lon, p.prob / heatMax]);
setTimeout(() => {
if (!leafletMap) {
leafletMap = L.map('map', { zoomControl: true, attributionControl: true });
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
attribution: '© <a href="https://carto.com/">CARTO</a>',
}).addTo(leafletMap);
}
leafletMap.invalidateSize();
if (heatLayer) leafletMap.removeLayer(heatLayer);
heatLayer = L.heatLayer(heatPoints, { radius: 28, blur: 20, maxZoom: 17, max: 1.0 }).addTo(leafletMap);
leafletMap.fitBounds(L.latLngBounds(heatPoints.map(p => [p[0], p[1]])).pad(0.25));
}, 0);
} catch (err) {
status.textContent = err.message;
} finally {
predictBtn.disabled = false;
predictBtn.textContent = 'Predict location';
}
});
</script>
</body>
</html>