<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ezu — Ezu Style live editor</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<style>
:root { color-scheme: light; }
html, body { margin: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.container { display: flex; height: 100vh; }
.panel { width: 38%; min-width: 320px; display: flex; flex-direction: column; border-right: 1px solid #d8d2bf; background: #fdfbf1; }
.toolbar { padding: 10px 12px; background: #f5eedc; border-bottom: 1px solid #d8d2bf; display: flex; gap: 10px; align-items: center; }
.toolbar h1 { margin: 0; font-size: 14px; font-weight: 600; color: #5a4636; letter-spacing: 0.02em; }
.toolbar .sp { flex: 1; }
.toolbar button { padding: 6px 14px; background: #5a4636; color: #fdfbf1; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer; }
.toolbar button:hover { background: #4a3424; }
.toolbar button:disabled { background: #b0a08e; cursor: not-allowed; }
.toolbar button.secondary { background: #c9bfa8; color: #4a3424; padding: 6px 10px; font-weight: 500; }
.toolbar button.secondary:hover { background: #b8ad94; }
.toolbar button.secondary.on { background: #387a45; color: #fdfbf1; }
.toolbar button.secondary.on:hover { background: #2c6238; }
.toolbar .group { display: flex; gap: 4px; }
.toolbar .status { font-size: 12px; color: #6a5040; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; max-width: 60%; text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.toolbar .status.error { color: #a03434; }
.toolbar .status.ok { color: #387a45; }
.toolbar .status.checking { color: #a07a3a; }
.toolbar .auto-mark { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: #387a45; opacity: 0; transition: opacity 0.3s; white-space: nowrap; }
.toolbar .auto-mark.show { opacity: 1; }
textarea {
flex: 1; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.45;
padding: 12px; border: 0; outline: none; resize: none; background: #fdfbf1; color: #382a1f;
tab-size: 2;
}
textarea.invalid { background: #fdf3ef; }
.ext-banner { display: none; background: #f5e6b2; border-bottom: 1px solid #d8c382; padding: 6px 10px; gap: 8px; align-items: center; font-size: 11px; color: #5a4636; }
.ext-banner.open { display: flex; }
.ext-banner .msg { flex: 1; }
.ext-banner button { padding: 3px 10px; background: #5a4636; color: #fdfbf1; border: 0; border-radius: 3px; font-size: 11px; font-weight: 600; cursor: pointer; }
.ext-banner button.dismiss { background: transparent; color: #5a4636; font-weight: 400; padding: 3px 6px; }
.ext-banner button.dismiss:hover { background: #ead9a5; }
.map-wrap { flex: 1; position: relative; }
#map { position: absolute; inset: 0; }
.maplibregl-canvas { background: #f8f5e8; }
kbd { background: #e6dfc8; padding: 1px 5px; border-radius: 3px; font-family: inherit; font-size: 11px; }
.inspect-toolbar { position: absolute; top: 10px; left: 10px; z-index: 5; background: #fdfbf1; border: 1px solid #d8d2bf; border-radius: 4px; padding: 6px 10px; font-size: 12px; display: flex; gap: 8px; align-items: center; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.inspect-toolbar button { padding: 4px 10px; background: #5a4636; color: #fdfbf1; border: 0; border-radius: 3px; font-weight: 600; cursor: pointer; font-size: 11px; }
.inspect-toolbar button.off { background: #c9bfa8; color: #4a3424; }
.inspect-toolbar .z-indicator { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: #5a4636; padding: 2px 6px; background: #f5eedc; border-radius: 3px; min-width: 56px; text-align: center; }
.inspect-panel { position: absolute; top: 60px; left: 10px; z-index: 5; background: #fdfbf1; border: 1px solid #d8d2bf; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); width: 220px; max-height: calc(100vh - 100px); display: none; flex-direction: column; overflow: hidden; }
.inspect-panel.open { display: flex; }
.inspect-panel header { padding: 8px 10px; background: #f5eedc; border-bottom: 1px solid #d8d2bf; font-size: 11px; font-weight: 600; color: #5a4636; display: flex; align-items: center; gap: 6px; }
.inspect-panel header .sp { flex: 1; }
.inspect-panel header .rescan { font-size: 10px; padding: 2px 6px; background: #c9bfa8; color: #4a3424; border: 0; border-radius: 3px; cursor: pointer; }
.inspect-list { overflow-y: auto; padding: 6px 4px; }
.inspect-row { display: flex; align-items: center; gap: 6px; padding: 4px 8px; font-size: 11px; color: #382a1f; cursor: pointer; user-select: none; }
.inspect-row:hover { background: #f5eedc; }
.inspect-row input { margin: 0; }
.inspect-row .sw { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.inspect-row .name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inspect-row .ct { color: #8a7a6a; font-size: 10px; }
.inspect-empty { padding: 12px; font-size: 11px; color: #8a7a6a; text-align: center; }
.params-panel { position: absolute; top: 10px; right: 48px; z-index: 5; background: #fdfbf1; border: 1px solid #d8d2bf; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); width: 240px; max-height: calc(100vh - 60px); display: none; flex-direction: column; overflow: hidden; }
.params-panel.open { display: flex; }
.params-panel header { padding: 8px 10px; background: #f5eedc; border-bottom: 1px solid #d8d2bf; font-size: 11px; font-weight: 600; color: #5a4636; display: flex; align-items: center; gap: 6px; }
.params-panel header .sp { flex: 1; }
.params-panel header .reset { font-size: 10px; padding: 2px 6px; background: #c9bfa8; color: #4a3424; border: 0; border-radius: 3px; cursor: pointer; }
.params-list { overflow-y: auto; padding: 8px 10px; display: flex; flex-direction: column; gap: 10px; }
.param-row { display: flex; flex-direction: column; gap: 3px; }
.param-row .head { display: flex; align-items: center; gap: 6px; }
.param-row .pname { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: #382a1f; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.param-row .pname.changed { color: #387a45; font-weight: 600; }
.param-row .pval { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; color: #6a5040; }
.param-row input[type=range] { width: 100%; accent-color: #5a4636; margin: 0; }
.param-row input[type=number] { width: 80px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; padding: 2px 4px; border: 1px solid #d8d2bf; border-radius: 3px; background: #fff; color: #382a1f; }
.param-row input[type=color] { width: 36px; height: 22px; padding: 0; border: 1px solid #d8d2bf; border-radius: 3px; background: #fff; cursor: pointer; }
.param-row .colorbox { display: flex; align-items: center; gap: 6px; }
.param-row .phex { flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; padding: 2px 4px; border: 1px solid #d8d2bf; border-radius: 3px; background: #fff; color: #382a1f; }
.param-row .phex.bad { border-color: #a03434; color: #a03434; }
.param-row input[type=checkbox] { margin: 0; accent-color: #5a4636; }
.params-empty { padding: 12px; font-size: 11px; color: #8a7a6a; text-align: center; }
.feat-popup { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; max-width: 380px; }
.feat-popup h4 { margin: 0 0 6px 0; font-size: 11px; color: #5a4636; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
.feat-popup table { border-collapse: collapse; width: 100%; }
.feat-popup td { padding: 2px 6px; border-bottom: 1px solid #ece5d0; vertical-align: top; word-break: break-all; }
.feat-popup td:first-child { color: #6a5040; white-space: nowrap; }
.feat-popup .count { color: #8a7a6a; font-size: 10px; margin-bottom: 4px; }
</style>
</head>
<body>
<div class="container">
<aside class="panel">
<div class="toolbar">
<h1>Ezu Style</h1>
<div class="group">
<button class="secondary" id="open-file" title="Open a .json style from disk">Open…</button>
<button class="secondary" id="open-url" title="Fetch a style from an http(s) URL">URL…</button>
<button class="secondary" id="save-file" title="Download the current style as a .json file">Save</button>
</div>
<span class="sp"></span>
<span class="status" id="status" title="">loading…</span>
<span class="auto-mark" id="auto-mark" title="Last auto-reload from disk"></span>
<button class="secondary" id="live-toggle" title="Live preview: auto-apply on valid edits">live: off</button>
<button id="apply" title="Apply (⌘↵ / Ctrl+↵)" disabled>Apply</button>
</div>
<div class="ext-banner" id="ext-banner">
<span class="msg" id="ext-banner-text">File changed on disk.</span>
<button id="ext-banner-reload">Reload</button>
<button id="ext-banner-dismiss" class="dismiss" title="Ignore this update">×</button>
</div>
<textarea id="json" spellcheck="false" placeholder="Loading style..."></textarea>
<input type="file" id="open-file-input" accept=".json,application/json" style="display:none" />
</aside>
<div class="map-wrap">
<div id="map"></div>
<div class="inspect-toolbar">
<span class="z-indicator" id="zoom-indicator" title="Current map zoom · click to copy z/center">z —</span>
<button id="grid-toggle" class="off" title="Show XYZ tile boundaries + z/x/y labels">grid: off</button>
<button id="inspect-toggle" class="off" title="Overlay source MVT features for inspection">inspect: off</button>
<button id="raster-toggle" title="Toggle the ezu rendered raster output">render: on</button>
</div>
<div class="params-panel" id="params-panel">
<header>
<span>Params</span>
<span class="sp"></span>
<button class="reset" id="params-reset" title="Reset every param to its declared default">reset</button>
</header>
<div class="params-list" id="params-list">
<div class="params-empty">No params declared.</div>
</div>
</div>
<div class="inspect-panel" id="inspect-panel">
<header>
<span>Layers</span>
<span class="sp"></span>
<button class="rescan" id="rescan" title="Re-scan tile at current center">rescan</button>
</header>
<div class="inspect-list" id="inspect-list">
<div class="inspect-empty">No tile scanned yet.</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ajv@8.17.1/dist/ajv7.bundle.min.js"></script>
<script>
(() => {
const $json = document.getElementById('json');
const $apply = document.getElementById('apply');
const $status = document.getElementById('status');
const $inspectToggle = document.getElementById('inspect-toggle');
const $rasterToggle = document.getElementById('raster-toggle');
const $inspectPanel = document.getElementById('inspect-panel');
const $inspectList = document.getElementById('inspect-list');
const $rescan = document.getElementById('rescan');
let version = 0;
let tileSize = 512;
let attributionHtml = '\u00a9 <a href="https://github.com/reearth/ezu">ezu</a>';
const paramOverrides = new Map();
const PALETTE = ['#e76f51','#2a9d8f','#264653','#e9c46a','#f4a261','#a06ba1','#6a994e','#bc4749','#1f6f8b','#c1666b'];
const layerState = new Map(); let inspectOn = false;
let rasterVisible = true;
let rasterMaxzoom = 22;
function setStatus(text, cls = '', title = '') {
$status.textContent = text;
$status.title = title || text;
$status.className = 'status' + (cls ? ' ' + cls : '');
}
const map = new maplibregl.Map({
container: 'map',
style: {
version: 8,
sources: {},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': '#f8f5e8' } },
],
},
center: [139.767, 35.681], zoom: 11,
minZoom: 0,
maxZoom: 23,
attributionControl: { compact: true },
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
map.addControl(new maplibregl.ScaleControl({}), 'bottom-left');
function isAbort(e) {
return e && (e.name === 'AbortError' || e.message === 'AbortError' || e.code === 20);
}
map.on('error', (e) => {
if (isAbort(e && e.error)) return;
console.error('map error:', (e && e.error) || e);
});
function rasterTilesUrl() {
let q = '';
for (const [k, v] of paramOverrides) {
q += `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
}
return `${location.origin}/tiles/{z}/{x}/{y}.png?v=${version}${q}`;
}
function ensureRasterLayer() {
if (map.getSource('ezu-raster')) return;
map.addSource('ezu-raster', {
type: 'raster',
tiles: [rasterTilesUrl()],
tileSize,
minzoom: 0,
maxzoom: rasterMaxzoom,
attribution: attributionHtml,
});
map.addLayer({
id: 'ezu-raster',
type: 'raster',
source: 'ezu-raster',
paint: { 'raster-opacity': 1 },
layout: { visibility: rasterVisible ? 'visible' : 'none' },
});
}
function refreshRasterTiles() {
const src = map.getSource('ezu-raster');
if (!src) { ensureRasterLayer(); return; }
if (typeof src.setTiles === 'function') {
src.setTiles([rasterTilesUrl()]);
} else {
map.removeLayer('ezu-raster');
map.removeSource('ezu-raster');
ensureRasterLayer();
}
}
let validateSchema = null;
function blankComments(src) {
let out = '';
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === '"') {
let j = i + 1;
while (j < src.length) {
if (src[j] === '\\') { j += 2; continue; }
if (src[j] === '"') { j += 1; break; }
j += 1;
}
out += src.slice(i, j);
i = j;
} else if (c === '/' && src[i + 1] === '/') {
while (i < src.length && src[i] !== '\n') { out += ' '; i += 1; }
} else if (c === '/' && src[i + 1] === '*') {
const end = src.indexOf('*/', i + 2);
const stop = end === -1 ? src.length : end + 2;
for (; i < stop; i += 1) out += (src[i] === '\n' || src[i] === '\r') ? src[i] : ' ';
} else {
out += c;
i += 1;
}
}
return out;
}
function parseStyle(text) {
return JSON.parse(blankComments(text));
}
async function loadSchema() {
if (typeof Ajv === 'undefined') return;
try {
const r = await fetch('/schemas/ezu-style.json');
const schema = await r.json();
delete schema.$schema;
const ajv = new Ajv({ strict: false, allErrors: true });
validateSchema = ajv.compile(schema);
} catch (e) {
console.warn('schema validator init failed:', e);
}
}
async function loadStyle() {
const r = await fetch('/style');
const text = await r.text();
$json.value = text;
lastServerText = text;
try {
const parsed = parseStyle(text);
if (parsed['tile-size']) tileSize = parsed['tile-size'];
} catch (_) {}
version = 1;
ensureRasterLayer();
setStatus(`loaded (v${version})`, 'ok');
$apply.disabled = false;
}
function validate() {
const text = $json.value;
let parsed;
try {
parsed = parseStyle(text);
} catch (e) {
$json.classList.add('invalid');
setStatus('json: ' + e.message, 'error');
$apply.disabled = true;
return;
}
if (validateSchema && !validateSchema(parsed)) {
const err = validateSchema.errors[0];
const path = err.instancePath || '/';
$json.classList.add('invalid');
setStatus(`schema: ${path} ${err.message}`, 'error',
validateSchema.errors.map(e => `${e.instancePath || '/'} ${e.message}`).join('\n'));
$apply.disabled = true;
return;
}
$json.classList.remove('invalid');
setStatus('checking…', 'checking');
serverValidate(text);
}
let validateSeq = 0;
async function serverValidate(text) {
const my = ++validateSeq;
try {
const r = await fetch('/style/validate', { method: 'POST', body: text });
if (my !== validateSeq) return;
if (r.ok) {
setStatus(`valid · v${version}`, 'ok');
$apply.disabled = false;
if (liveMode) scheduleLiveApply();
} else {
const detail = (await r.text()).slice(0, 240);
setStatus(detail, 'error', detail);
$apply.disabled = true;
}
} catch (e) {
if (my !== validateSeq) return;
setStatus('check failed: ' + e, 'error');
}
}
let inputTimer = 0;
$json.addEventListener('input', () => {
clearTimeout(inputTimer);
inputTimer = setTimeout(validate, 300);
});
let applySeq = 0;
async function apply() {
const body = $json.value;
const my = ++applySeq;
setStatus('applying…', 'checking');
try {
const r = await fetch('/style', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body,
});
if (my !== applySeq) return; if (!r.ok) {
const detail = await r.text();
setStatus(detail.slice(0, 240), 'error', detail);
return;
}
const { version: v } = await r.json();
version = v;
lastServerText = body;
let nextTileSize = tileSize;
try {
const parsed = parseStyle(body);
if (parsed['tile-size']) nextTileSize = parsed['tile-size'];
} catch (_) {}
if (nextTileSize !== tileSize) {
tileSize = nextTileSize;
if (map.getLayer('ezu-raster')) map.removeLayer('ezu-raster');
if (map.getSource('ezu-raster')) map.removeSource('ezu-raster');
ensureRasterLayer();
} else {
refreshRasterTiles();
}
loadParams(); loadAttribution();
setStatus(`applied (v${v})`, 'ok');
} catch (e) {
setStatus('error: ' + e, 'error');
}
}
$apply.addEventListener('click', apply);
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
if (!$apply.disabled) apply();
}
});
function onServerReload(p) {
if (!p || typeof p.text !== 'string') return;
const dirty = $json.value !== lastServerText;
if (!dirty) {
applyExternal(p.text, p.mtime_ms, { source: 'server', version: p.version });
} else {
pendingExternal = {
text: p.text,
mtime: p.mtime_ms,
source: 'server',
version: p.version,
};
const ts = fmtTime(p.mtime_ms);
showExtBanner(`Style file changed at ${ts} (server) — you have unsaved edits.`);
}
}
let sse = null;
try {
sse = new EventSource('/style/events');
sse.addEventListener('reload', (e) => {
try { onServerReload(JSON.parse(e.data)); } catch (_) {}
});
} catch (e) {
console.warn('SSE setup failed:', e);
}
function detachServerWatch() {
if (sse) { sse.close(); sse = null; }
if (pendingExternal && pendingExternal.source === 'server') {
pendingExternal = null;
hideExtBanner();
}
}
const $liveToggle = document.getElementById('live-toggle');
let liveMode = false;
let liveTimer = 0;
const LIVE_DELAY = 250;
function scheduleLiveApply() {
clearTimeout(liveTimer);
liveTimer = setTimeout(() => {
if (liveMode && !$apply.disabled) apply();
}, LIVE_DELAY);
}
function setLive(on) {
liveMode = on;
$liveToggle.textContent = `live: ${on ? 'on' : 'off'}`;
$liveToggle.classList.toggle('on', on);
try { localStorage.setItem('ezu.live', on ? '1' : '0'); } catch (_) {}
if (on && !$apply.disabled) scheduleLiveApply();
}
$liveToggle.addEventListener('click', () => setLive(!liveMode));
try { if (localStorage.getItem('ezu.live') === '1') setLive(true); } catch (_) {}
const $openFile = document.getElementById('open-file');
const $openFileInput = document.getElementById('open-file-input');
const $openUrl = document.getElementById('open-url');
const $saveFile = document.getElementById('save-file');
const $extBanner = document.getElementById('ext-banner');
const $extBannerText = document.getElementById('ext-banner-text');
const $extBannerReload = document.getElementById('ext-banner-reload');
const $extBannerDismiss = document.getElementById('ext-banner-dismiss');
const $autoMark = document.getElementById('auto-mark');
let lastOpenedName = null;
let fileHandle = null;
let lastLoadedText = ''; let lastLoadedMtime = 0;
let lastServerText = ''; let pendingExternal = null; let watchTimer = 0;
const WATCH_INTERVAL_MS = 1500;
const hasFsAccess = typeof window.showOpenFilePicker === 'function';
function fmtTime(ms) {
const d = ms ? new Date(ms) : new Date();
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
function markAutoReloaded(ms) {
$autoMark.textContent = `↻ ${fmtTime(ms)}`;
$autoMark.classList.add('show');
}
function loadIntoEditor(text, sourceName) {
$json.value = text;
lastOpenedName = sourceName || null;
lastLoadedText = text;
pendingExternal = null;
hideExtBanner();
setStatus('loaded · unsaved', 'checking');
$apply.disabled = false;
validate();
}
function stopWatch() {
if (watchTimer) { clearInterval(watchTimer); watchTimer = 0; }
}
function startWatch() {
stopWatch();
if (!fileHandle) return;
watchTimer = setInterval(pollExternalChange, WATCH_INTERVAL_MS);
}
function showExtBanner(text) {
$extBannerText.textContent = text;
$extBanner.classList.add('open');
}
function hideExtBanner() {
$extBanner.classList.remove('open');
}
function applyExternal(text, mtime, opts = {}) {
$json.value = text;
if (opts.source === 'picker') {
lastLoadedText = text;
lastLoadedMtime = mtime;
} else if (opts.source === 'server') {
lastServerText = text;
if (opts.version && opts.version !== version) {
version = opts.version;
refreshRasterTiles();
loadParams(); }
}
pendingExternal = null;
hideExtBanner();
setStatus('auto-reloaded', 'ok');
markAutoReloaded(mtime);
validate();
}
async function pollExternalChange() {
if (!fileHandle) return;
let file;
try {
file = await fileHandle.getFile();
} catch (e) {
stopWatch();
return;
}
if (file.lastModified === lastLoadedMtime) return;
let text;
try { text = await file.text(); } catch (_) { return; }
if (text === lastLoadedText) {
lastLoadedMtime = file.lastModified;
return;
}
const dirty = $json.value !== lastLoadedText;
if (!dirty) {
applyExternal(text, file.lastModified, { source: 'picker' });
} else {
pendingExternal = { text, mtime: file.lastModified, source: 'picker' };
const ts = fmtTime(file.lastModified);
showExtBanner(`File changed on disk at ${ts} — you have unsaved edits.`);
}
}
$extBannerReload.addEventListener('click', () => {
if (!pendingExternal) { hideExtBanner(); return; }
applyExternal(pendingExternal.text, pendingExternal.mtime, {
source: pendingExternal.source,
version: pendingExternal.version,
});
});
$extBannerDismiss.addEventListener('click', () => {
if (pendingExternal) {
if (pendingExternal.source === 'picker') {
lastLoadedMtime = pendingExternal.mtime;
lastLoadedText = pendingExternal.text;
} else if (pendingExternal.source === 'server') {
lastServerText = pendingExternal.text;
}
pendingExternal = null;
}
hideExtBanner();
});
async function openWithPicker() {
const [handle] = await window.showOpenFilePicker({
multiple: false,
types: [{ description: 'Ezu Style JSON', accept: { 'application/json': ['.json'] } }],
});
const file = await handle.getFile();
const text = await file.text();
fileHandle = handle;
detachServerWatch();
loadIntoEditor(text, handle.name.replace(/\.json$/i, ''));
lastLoadedMtime = file.lastModified;
startWatch();
}
$openFile.addEventListener('click', async () => {
if (hasFsAccess) {
try {
await openWithPicker();
} catch (e) {
if (e && e.name === 'AbortError') return;
setStatus('open failed: ' + e, 'error');
}
} else {
$openFileInput.click();
}
});
$openFileInput.addEventListener('change', async () => {
const file = $openFileInput.files && $openFileInput.files[0];
if (!file) return;
try {
const text = await file.text();
fileHandle = null;
stopWatch();
detachServerWatch();
loadIntoEditor(text, file.name.replace(/\.json$/i, ''));
lastLoadedMtime = file.lastModified;
} catch (e) {
setStatus('open failed: ' + e, 'error');
} finally {
$openFileInput.value = '';
}
});
$openUrl.addEventListener('click', async () => {
const url = prompt('Style URL (http or https):');
if (!url) return;
if (!/^https?:\/\//i.test(url)) {
setStatus('open url: must start with http(s)://', 'error');
return;
}
setStatus('fetching…', 'checking');
try {
const r = await fetch(`/style/fetch?url=${encodeURIComponent(url)}`);
const body = await r.text();
if (!r.ok) {
setStatus(body.slice(0, 240) || `http ${r.status}`, 'error');
return;
}
const base = url.split('?')[0].split('/').pop() || 'style';
fileHandle = null;
stopWatch();
detachServerWatch();
loadIntoEditor(body, base.replace(/\.json$/i, ''));
} catch (e) {
setStatus('fetch failed: ' + e, 'error');
}
});
function downloadAsBlob(text, baseName) {
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${baseName}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function saveToHandle(text) {
if (!fileHandle) return false;
if (fileHandle.queryPermission) {
const q = await fileHandle.queryPermission({ mode: 'readwrite' });
if (q !== 'granted') {
const r = await fileHandle.requestPermission({ mode: 'readwrite' });
if (r !== 'granted') return false;
}
}
const w = await fileHandle.createWritable();
await w.write(text);
await w.close();
const f = await fileHandle.getFile();
lastLoadedText = text;
lastLoadedMtime = f.lastModified;
lastServerText = text;
pendingExternal = null;
hideExtBanner();
return true;
}
$saveFile.addEventListener('click', async () => {
const text = $json.value;
let baseName = lastOpenedName || 'style';
try {
const parsed = parseStyle(text);
if (parsed && typeof parsed.name === 'string' && parsed.name.trim()) {
baseName = parsed.name.trim().replace(/[\\/:*?"<>|]+/g, '_');
}
} catch (_) {}
if (fileHandle) {
try {
const ok = await saveToHandle(text);
if (ok) {
setStatus(`saved · ${fileHandle.name}`, 'ok');
return;
}
} catch (e) {
setStatus('save failed: ' + e, 'error');
return;
}
}
downloadAsBlob(text, baseName);
});
function pickColor() {
return PALETTE[layerState.size % PALETTE.length];
}
function tileAtCenter() {
const c = map.getCenter();
const z = Math.min(14, Math.max(0, Math.floor(map.getZoom())));
const n = Math.pow(2, z);
const x = Math.floor((c.lng + 180) / 360 * n);
const latRad = c.lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return { z, x: ((x % n) + n) % n, y: Math.max(0, Math.min(n - 1, y)) };
}
async function fetchMeta() {
const { z, x, y } = tileAtCenter();
try {
const r = await fetch(`/mvt-meta/${z}/${x}/${y}`);
if (!r.ok) return null;
const meta = await r.json();
return meta.layers || [];
} catch (e) {
console.warn('mvt-meta failed:', e);
return null;
}
}
function ensureMvtSource() {
if (map.getSource('ezu-mvt')) return;
map.addSource('ezu-mvt', {
type: 'vector',
tiles: [`${location.origin}/mvt/{z}/{x}/{y}`],
minzoom: 0,
maxzoom: 14,
});
}
function inspectLayerIds(name) {
const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_');
return {
fill: `mvt-${safe}-fill`,
line: `mvt-${safe}-line`,
circle: `mvt-${safe}-circle`,
};
}
function addStyleLayersFor(name, geoms, color) {
const ids = inspectLayerIds(name);
if (geoms.has('polygon') && !map.getLayer(ids.fill)) {
map.addLayer({
id: ids.fill,
type: 'fill',
source: 'ezu-mvt',
'source-layer': name,
filter: ['==', ['geometry-type'], 'Polygon'],
paint: { 'fill-color': color, 'fill-opacity': 0.15, 'fill-outline-color': color },
});
}
if (geoms.has('line') || geoms.has('polygon')) {
if (!map.getLayer(ids.line)) {
map.addLayer({
id: ids.line,
type: 'line',
source: 'ezu-mvt',
'source-layer': name,
filter: ['any', ['==', ['geometry-type'], 'LineString'], ['==', ['geometry-type'], 'Polygon']],
paint: { 'line-color': color, 'line-width': 1.1, 'line-opacity': 0.9 },
});
}
}
if (geoms.has('point') && !map.getLayer(ids.circle)) {
map.addLayer({
id: ids.circle,
type: 'circle',
source: 'ezu-mvt',
'source-layer': name,
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 4,
'circle-color': color,
'circle-stroke-color': '#222',
'circle-stroke-width': 0.5,
'circle-opacity': 0.85,
},
});
}
}
function setLayerVisibility(name, visible) {
const ids = inspectLayerIds(name);
const v = visible ? 'visible' : 'none';
for (const id of Object.values(ids)) {
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', v);
}
}
function removeAllInspectLayers() {
for (const name of layerState.keys()) {
const ids = inspectLayerIds(name);
for (const id of Object.values(ids)) {
if (map.getLayer(id)) map.removeLayer(id);
}
}
if (map.getSource('ezu-mvt')) map.removeSource('ezu-mvt');
}
function renderList() {
if (layerState.size === 0) {
$inspectList.innerHTML = '<div class="inspect-empty">No layers found in tile at center. Pan/zoom and rescan.</div>';
return;
}
const rows = [];
const sorted = Array.from(layerState.entries()).sort((a, b) => a[0].localeCompare(b[0]));
for (const [name, st] of sorted) {
const geoms = Array.from(st.geoms).join('+');
rows.push(`<label class="inspect-row">
<input type="checkbox" data-name="${escapeAttr(name)}" ${st.visible ? 'checked' : ''} />
<span class="sw" style="background:${st.color}"></span>
<span class="name" title="${escapeAttr(name)} (${geoms}, ${st.count})">${escapeHtml(name)}</span>
<span class="ct">${st.count}</span>
</label>`);
}
$inspectList.innerHTML = rows.join('');
for (const el of $inspectList.querySelectorAll('input[type=checkbox]')) {
el.addEventListener('change', (e) => {
const name = e.target.getAttribute('data-name');
const st = layerState.get(name);
if (!st) return;
st.visible = e.target.checked;
setLayerVisibility(name, st.visible);
});
}
}
async function rescan() {
if (!inspectOn) return;
const layers = await fetchMeta();
if (!layers) return;
ensureMvtSource();
for (const l of layers) {
const geoms = new Set(l.geometry_types || []);
let st = layerState.get(l.name);
if (!st) {
st = { color: pickColor(), geoms, count: l.features, visible: true };
layerState.set(l.name, st);
} else {
for (const g of geoms) st.geoms.add(g);
st.count = l.features;
}
addStyleLayersFor(l.name, st.geoms, st.color);
setLayerVisibility(l.name, st.visible);
}
renderList();
}
function setInspect(on) {
inspectOn = on;
$inspectToggle.textContent = `inspect: ${on ? 'on' : 'off'}`;
$inspectToggle.classList.toggle('off', !on);
$inspectPanel.classList.toggle('open', on);
if (on) {
rescan();
} else {
removeAllInspectLayers();
layerState.clear();
renderList();
}
}
$inspectToggle.addEventListener('click', () => setInspect(!inspectOn));
$rescan.addEventListener('click', rescan);
map.on('moveend', () => { if (inspectOn) rescan(); });
$rasterToggle.addEventListener('click', () => {
rasterVisible = !rasterVisible;
$rasterToggle.textContent = `render: ${rasterVisible ? 'on' : 'off'}`;
if (map.getLayer('ezu-raster')) {
map.setLayoutProperty('ezu-raster', 'visibility', rasterVisible ? 'visible' : 'none');
}
});
const $paramsPanel = document.getElementById('params-panel');
const $paramsList = document.getElementById('params-list');
const $paramsReset = document.getElementById('params-reset');
let paramDecls = {};
let paramTimer = 0;
function scheduleParamRefresh() {
clearTimeout(paramTimer);
paramTimer = setTimeout(refreshRasterTiles, 200);
}
function fmtNum(v) {
const n = Number(v);
return Number.isInteger(n) ? String(n) : n.toFixed(Math.abs(n) < 1 ? 3 : 2).replace(/0+$/, '').replace(/\.$/, '');
}
function parseHexColor(s) {
const t = String(s == null ? '' : s).trim().replace(/^#/, '');
if (!/^[0-9a-fA-F]+$/.test(t)) return null;
const x = t.toLowerCase();
if (x.length === 3 || x.length === 4) {
return '#' + Array.from(x, (c) => c + c).join('');
}
return x.length === 6 || x.length === 8 ? '#' + x : null;
}
function normColor(s) {
let c = parseHexColor(s) || String(s || '').toLowerCase();
if (c.length === 9 && c.endsWith('ff')) c = c.slice(0, 7);
return c;
}
async function loadAttribution() {
let next = '\u00a9 <a href="https://github.com/reearth/ezu">ezu</a>';
try {
const r = await fetch('/style/attribution');
if (r.ok) {
const body = await r.json();
if (body.attribution) next += ' | ' + body.attribution;
}
} catch (e) {
console.warn('attribution load failed:', e);
}
if (next === attributionHtml) return;
attributionHtml = next;
if (map.getSource('ezu-raster')) {
if (map.getLayer('ezu-raster')) map.removeLayer('ezu-raster');
map.removeSource('ezu-raster');
ensureRasterLayer();
}
}
async function loadParams() {
let schema;
try {
const r = await fetch('/style/params');
if (!r.ok) return;
schema = await r.json();
} catch (e) {
console.warn('params load failed:', e);
return;
}
paramDecls = (schema && schema.properties) || {};
let dropped = false;
for (const k of Array.from(paramOverrides.keys())) {
if (!paramDecls[k]) { paramOverrides.delete(k); dropped = true; }
}
renderParams();
if (dropped) refreshRasterTiles();
}
function renderParams() {
const names = Object.keys(paramDecls);
$paramsList.textContent = '';
$paramsPanel.classList.toggle('open', names.length > 0);
if (!names.length) return;
for (const name of names) {
const decl = paramDecls[name];
const row = document.createElement('div');
row.className = 'param-row';
if (decl.description) row.title = decl.description;
const head = document.createElement('div');
head.className = 'head';
const label = document.createElement('span');
label.className = 'pname' + (paramOverrides.has(name) ? ' changed' : '');
label.textContent = name;
head.appendChild(label);
const setOverride = (raw, isDefault) => {
if (isDefault) paramOverrides.delete(name);
else paramOverrides.set(name, raw);
label.classList.toggle('changed', !isDefault);
scheduleParamRefresh();
};
if (decl.type === 'number') {
const cur = paramOverrides.has(name) ? Number(paramOverrides.get(name)) : Number(decl.default ?? 0);
const val = document.createElement('span');
val.className = 'pval';
val.textContent = fmtNum(cur);
head.appendChild(val);
row.appendChild(head);
const bounded = typeof decl.minimum === 'number' && typeof decl.maximum === 'number';
const input = document.createElement('input');
if (bounded) {
input.type = 'range';
input.min = decl.minimum;
input.max = decl.maximum;
input.step = (decl.maximum - decl.minimum) / 100;
} else {
input.type = 'number';
if (typeof decl.minimum === 'number') input.min = decl.minimum;
if (typeof decl.maximum === 'number') input.max = decl.maximum;
}
input.value = cur;
input.addEventListener('input', () => {
const n = Number(input.value);
if (!Number.isFinite(n)) return;
val.textContent = fmtNum(n);
setOverride(String(n), n === Number(decl.default));
});
row.appendChild(input);
} else if (decl.format === 'color') {
row.appendChild(head);
let cur = parseHexColor(paramOverrides.has(name)
? paramOverrides.get(name)
: decl.default) || '#000000';
const box = document.createElement('div');
box.className = 'colorbox';
const input = document.createElement('input');
input.type = 'color';
input.value = cur.slice(0, 7);
const hex = document.createElement('input');
hex.type = 'text';
hex.className = 'phex';
hex.value = cur;
hex.spellcheck = false;
hex.autocapitalize = 'off';
hex.setAttribute('autocomplete', 'off');
hex.title = 'Hex color — #fff, #ffff, #ffffff or #ffffffff';
const commit = (next) => {
cur = next;
setOverride(cur, normColor(cur) === normColor(decl.default));
};
input.addEventListener('input', () => {
const alpha = cur.length === 9 ? cur.slice(7) : '';
hex.classList.remove('bad');
hex.value = input.value + alpha;
commit(hex.value);
});
hex.addEventListener('input', () => {
const parsed = parseHexColor(hex.value);
hex.classList.toggle('bad', parsed === null);
if (parsed === null) return;
input.value = parsed.slice(0, 7);
commit(parsed);
});
hex.addEventListener('change', () => {
hex.classList.remove('bad');
hex.value = cur;
});
hex.addEventListener('blur', () => {
hex.classList.remove('bad');
hex.value = cur;
});
hex.addEventListener('focus', () => hex.select());
box.appendChild(input);
box.appendChild(hex);
row.appendChild(box);
} else if (decl.type === 'boolean') {
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = paramOverrides.has(name)
? paramOverrides.get(name) === 'true'
: !!decl.default;
input.addEventListener('change', () => {
setOverride(String(input.checked), input.checked === !!decl.default);
});
head.insertBefore(input, label);
row.appendChild(head);
} else {
continue; }
$paramsList.appendChild(row);
}
}
$paramsReset.addEventListener('click', () => {
if (!paramOverrides.size) return;
paramOverrides.clear();
renderParams();
refreshRasterTiles();
});
const $zoom = document.getElementById('zoom-indicator');
const $gridToggle = document.getElementById('grid-toggle');
function updateZoom() {
$zoom.textContent = `z ${map.getZoom().toFixed(2)}`;
}
map.on('move', updateZoom);
map.on('zoom', updateZoom);
$zoom.addEventListener('click', () => {
const c = map.getCenter();
const text = `${map.getZoom().toFixed(2)} @ ${c.lat.toFixed(5)},${c.lng.toFixed(5)}`;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => {});
}
$zoom.title = `copied: ${text}`;
});
maplibregl.addProtocol('grid', async (params) => {
const m = /^grid:\/\/(\d+)\/(\d+)\/(\d+)/.exec(params.url);
if (!m) throw new Error('bad grid url: ' + params.url);
const z = +m[1], x = +m[2], y = +m[3];
const size = 256;
const canvas = typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(size, size)
: Object.assign(document.createElement('canvas'), { width: size, height: size });
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, size, size);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 4;
ctx.strokeRect(2, 2, size - 4, size - 4);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 2;
ctx.strokeRect(2, 2, size - 4, size - 4);
const label = `${z}/${x}/${y}`;
ctx.font = 'bold 11px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const padX = 6, h = 16;
const w = Math.ceil(ctx.measureText(label).width) + padX * 2;
const rx = Math.round(size / 2 - w / 2);
const ry = Math.round(size / 2 - h / 2);
ctx.fillStyle = 'rgba(253,251,241,0.9)';
ctx.fillRect(rx, ry, w, h);
ctx.strokeStyle = 'rgba(216,210,191,1)';
ctx.lineWidth = 1;
ctx.strokeRect(rx + 0.5, ry + 0.5, w - 1, h - 1);
ctx.fillStyle = '#382a1f';
ctx.fillText(label, size / 2, size / 2 + 0.5);
const blob = canvas.convertToBlob
? await canvas.convertToBlob({ type: 'image/png' })
: await new Promise((res) => canvas.toBlob(res, 'image/png'));
const buf = await blob.arrayBuffer();
return { data: new Uint8Array(buf) };
});
let gridOn = false;
function setGrid(on) {
gridOn = on;
$gridToggle.textContent = `grid: ${on ? 'on' : 'off'}`;
$gridToggle.classList.toggle('off', !on);
if (on) {
if (!map.getSource('tile-grid')) {
map.addSource('tile-grid', {
type: 'raster',
tiles: ['grid://{z}/{x}/{y}'],
tileSize: 256,
minzoom: 0,
maxzoom: 22,
});
map.addLayer({ id: 'tile-grid', type: 'raster', source: 'tile-grid' });
}
} else {
if (map.getLayer('tile-grid')) map.removeLayer('tile-grid');
if (map.getSource('tile-grid')) map.removeSource('tile-grid');
}
}
$gridToggle.addEventListener('click', () => setGrid(!gridOn));
function visibleInspectLayerIds() {
const ids = [];
for (const [name, st] of layerState) {
if (!st.visible) continue;
const sub = inspectLayerIds(name);
for (const id of Object.values(sub)) if (map.getLayer(id)) ids.push(id);
}
return ids;
}
map.on('click', (e) => {
if (!inspectOn) return;
const ids = visibleInspectLayerIds();
if (!ids.length) return;
const feats = map.queryRenderedFeatures(e.point, { layers: ids });
if (!feats.length) return;
new maplibregl.Popup({ maxWidth: '420px', closeOnClick: true })
.setLngLat(e.lngLat)
.setHTML(renderFeaturesHtml(feats))
.addTo(map);
});
map.on('mousemove', (e) => {
if (!inspectOn) { map.getCanvas().style.cursor = ''; return; }
const ids = visibleInspectLayerIds();
if (!ids.length) { map.getCanvas().style.cursor = ''; return; }
const feats = map.queryRenderedFeatures(e.point, { layers: ids });
map.getCanvas().style.cursor = feats.length ? 'pointer' : '';
});
function renderFeaturesHtml(feats) {
const groups = new Map();
for (const f of feats) {
const key = f.sourceLayer || '?';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(f);
}
const parts = [`<div class="feat-popup">`];
if (groups.size > 1 || feats.length > 1) {
parts.push(`<div class="count">${feats.length} feature(s) across ${groups.size} layer(s)</div>`);
}
for (const [name, fs] of groups) {
const f = fs[0];
const props = f.properties || {};
parts.push(`<h4>${escapeHtml(name)}${fs.length > 1 ? ` <span class="count">+${fs.length - 1}</span>` : ''}</h4>`);
const rows = Object.entries(props)
.map(([k, v]) => `<tr><td>${escapeHtml(k)}</td><td>${escapeHtml(String(v))}</td></tr>`)
.join('');
parts.push(rows ? `<table>${rows}</table>` : `<div class="count">(no properties)</div>`);
}
parts.push(`</div>`);
return parts.join('');
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function escapeAttr(s) { return escapeHtml(s); }
async function loadConfig() {
try {
const r = await fetch('/config');
if (!r.ok) return;
const cfg = await r.json();
if (Number.isFinite(cfg.rasterMaxzoom)) {
rasterMaxzoom = cfg.rasterMaxzoom;
map.setMaxZoom(Math.min(24, rasterMaxzoom + 1));
}
} catch (e) {
console.warn('config load failed:', e);
}
}
map.on('load', async () => {
updateZoom();
await loadConfig();
await loadSchema();
await loadAttribution();
await loadStyle();
loadParams();
});
})();
</script>
</body>
</html>