Initial XPro map visualizer

This commit is contained in:
2026-07-08 10:19:59 +01:00
commit c0686d0c5a
12 changed files with 965 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
.env
Dockerfile
README.md
npm-debug.log
node_modules
+5
View File
@@ -0,0 +1,5 @@
APP_PORT=4173
WMS_ENDPOINT=https://xpro-viz.jfig.net/geoserver/xpro/wms
ALLOWED_WMS_ENDPOINTS=https://xpro-viz.jfig.net/geoserver/xpro/wms
GEOSERVER_USERNAME=admin
GEOSERVER_PASSWORD=change-me
+3
View File
@@ -0,0 +1,3 @@
node_modules
npm-debug.log
.env
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY index.html ./
COPY app.js ./
COPY styles.css ./
ENV HOST=0.0.0.0
ENV PORT=4173
EXPOSE 4173
USER node
CMD ["npm", "start"]
+64
View File
@@ -0,0 +1,64 @@
# XPro Map Visualizer
Tiny static WMS viewer for the GeoServer endpoint at `https://xpro-viz.jfig.net/geoserver/xpro/wms`.
## Run locally
Start the included Node server:
```bash
npm install
npm start
```
Then open `http://localhost:4173`.
## Run with Docker
Using Docker Compose:
```bash
cp .env.example .env
# Edit .env and set GEOSERVER_PASSWORD.
docker compose up --build
```
Then open `http://localhost:4173`.
Build the image:
```bash
docker build -t xprov-map-visualizer .
```
Run the container:
```bash
docker run --rm -p 4173:4173 xprov-map-visualizer
```
Then open `http://localhost:4173`.
The browser never receives the GeoServer password. Configure it in `.env` for
the backend proxy:
```bash
GEOSERVER_USERNAME=admin
GEOSERVER_PASSWORD=change-me
```
To change the default WMS endpoint or allow additional WMS endpoints through
the proxy, edit `.env`:
```bash
WMS_ENDPOINT=https://xpro-viz.jfig.net/geoserver/xpro/wms
ALLOWED_WMS_ENDPOINTS=https://xpro-viz.jfig.net/geoserver/xpro/wms,https://example.com/geoserver/wms
```
## Notes
- The app does not store the password in the repo, local storage, or the browser.
- The included server proxies WMS requests so the browser does not run into cross-origin auth issues.
- The proxy only allows configured WMS endpoints. By default it allows the XPro GeoServer endpoint.
- It fetches WMS capabilities with backend HTTP Basic Auth and loads all discovered layers on top of OpenStreetMap.
- The currently published layers discovered during setup were `parcelas` and `predios`.
+200
View File
@@ -0,0 +1,200 @@
const DEFAULT_CENTER = [-8.65, 39.55];
const API_CAPABILITIES = "/api/capabilities";
const API_WMS = "/api/wms";
const els = {
status: document.getElementById("status"),
layerList: document.getElementById("layer-list"),
layerCount: document.getElementById("layer-count"),
};
const osmLayer = new ol.layer.Tile({
source: new ol.source.OSM(),
});
const wmsSource = new ol.source.TileWMS({
url: API_WMS,
params: {
LAYERS: "",
TILED: true,
FORMAT: "image/png",
TRANSPARENT: true,
},
crossOrigin: "anonymous",
});
const wmsLayer = new ol.layer.Tile({
opacity: 0.8,
visible: false,
source: wmsSource,
});
const map = new ol.Map({
target: "map",
layers: [osmLayer, wmsLayer],
view: new ol.View({
center: ol.proj.fromLonLat(DEFAULT_CENTER),
zoom: 7,
}),
});
let availableLayers = [];
let selectedLayers = [];
loadLayers();
async function loadLayers() {
updateStatus("Loading WMS layers...");
try {
const capabilities = await loadCapabilities();
availableLayers = extractLayers(capabilities);
selectedLayers = availableLayers.map((layer) => layer.name);
renderLayerList();
syncSelectedLayers();
fitToLayerExtent(availableLayers[0]);
updateStatus(`Showing ${availableLayers.length} layer(s) from GeoServer.`, "success");
} catch (error) {
console.error(error);
updateStatus(error.message || "Unable to load the WMS service.", "error");
availableLayers = [];
selectedLayers = [];
renderLayerList();
syncSelectedLayers();
}
}
async function loadCapabilities() {
const url = new URL(API_CAPABILITIES, window.location.origin);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GeoServer connection failed with status ${response.status}.`);
}
return response.text();
}
function extractLayers(xmlText) {
const parser = new DOMParser();
const xml = parser.parseFromString(xmlText, "text/xml");
const capabilityNode = firstChildByName(xml.documentElement, "Capability");
const rootLayerNode = firstChildByName(capabilityNode, "Layer");
const nodes = collectNamedLayers(rootLayerNode);
return nodes
.map((layerNode) => {
const name = textFromChild(layerNode, "Name");
const title = textFromChild(layerNode, "Title") || name;
const bbox = firstChildByName(layerNode, "EX_GeographicBoundingBox");
const extent = bbox
? [
Number(textFromChild(bbox, "westBoundLongitude")),
Number(textFromChild(bbox, "southBoundLatitude")),
Number(textFromChild(bbox, "eastBoundLongitude")),
Number(textFromChild(bbox, "northBoundLatitude")),
]
: null;
return { name, title, extent };
})
.filter((layer) => layer.name);
}
function renderLayerList() {
els.layerCount.textContent = String(availableLayers.length);
if (!availableLayers.length) {
els.layerList.className = "layer-list empty";
els.layerList.textContent = "No published layers were returned by the service.";
return;
}
els.layerList.className = "layer-list";
els.layerList.replaceChildren();
availableLayers.forEach((layer) => {
const label = document.createElement("label");
label.className = "layer-item";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = selectedLayers.includes(layer.name);
checkbox.addEventListener("change", () => {
selectedLayers = checkbox.checked
? [...new Set([...selectedLayers, layer.name])]
: selectedLayers.filter((name) => name !== layer.name);
syncSelectedLayers();
if (checkbox.checked) {
fitToLayerExtent(layer);
}
});
const meta = document.createElement("div");
meta.className = "layer-meta";
const title = document.createElement("span");
title.className = "layer-title";
title.textContent = layer.title;
const name = document.createElement("span");
name.className = "layer-name";
name.textContent = layer.name;
meta.append(title, name);
label.append(checkbox, meta);
els.layerList.append(label);
});
}
function syncSelectedLayers() {
const orderedSelectedLayers = availableLayers
.map((layer) => layer.name)
.filter((name) => selectedLayers.includes(name));
wmsLayer.setVisible(selectedLayers.length > 0);
wmsSource.updateParams({
LAYERS: [...orderedSelectedLayers].reverse().join(","),
_: Date.now(),
});
}
function fitToLayerExtent(layer) {
if (!layer?.extent || layer.extent.some((value) => Number.isNaN(value))) {
return;
}
const projectedExtent = ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857");
map.getView().fit(projectedExtent, {
padding: [40, 40, 40, 40],
duration: 500,
maxZoom: 18,
});
}
function childElementsByName(node, localName) {
return [...(node?.children || [])].filter((child) => child.localName === localName);
}
function firstChildByName(node, localName) {
return childElementsByName(node, localName)[0] || null;
}
function textFromChild(node, localName) {
return firstChildByName(node, localName)?.textContent?.trim() || "";
}
function collectNamedLayers(node) {
return childElementsByName(node, "Layer").flatMap((layerNode) => {
const children = collectNamedLayers(layerNode);
return textFromChild(layerNode, "Name") ? [layerNode, ...children] : children;
});
}
function updateStatus(message, tone) {
els.status.textContent = message;
els.status.className = tone ? `status ${tone}` : "status";
}
+15
View File
@@ -0,0 +1,15 @@
services:
xprov-map-visualizer:
build:
context: .
image: xprov-map-visualizer:latest
container_name: xprov-map-visualizer
restart: unless-stopped
environment:
PORT: ${APP_PORT:-4173}
WMS_ENDPOINT: ${WMS_ENDPOINT:-https://xpro-viz.jfig.net/geoserver/xpro/wms}
ALLOWED_WMS_ENDPOINTS: ${ALLOWED_WMS_ENDPOINTS:-https://xpro-viz.jfig.net/geoserver/xpro/wms}
GEOSERVER_USERNAME: ${GEOSERVER_USERNAME:-admin}
GEOSERVER_PASSWORD: ${GEOSERVER_PASSWORD}
ports:
- "${APP_PORT:-4173}:${APP_PORT:-4173}"
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XPro Map Visualizer</title>
<link rel="stylesheet" href="./vendor/ol.css" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<h1>XPro Map Visualizer</h1>
</div>
<section class="panel">
<div class="panel-heading">
<h2>Layers</h2>
<span id="layer-count" class="badge">0</span>
</div>
<p id="status" class="status">Waiting for connection.</p>
<div id="layer-list" class="layer-list empty">
Loading published layers.
</div>
</section>
</aside>
<main class="map-wrap">
<div id="map" aria-label="Map view"></div>
</main>
</div>
<script src="./vendor/ol.js"></script>
<script src="./app.js"></script>
</body>
</html>
+169
View File
@@ -0,0 +1,169 @@
{
"name": "xprov-map-visualizer",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xprov-map-visualizer",
"version": "0.1.0",
"dependencies": {
"ol": "10.6.1"
}
},
"node_modules/@petamoriken/float16": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
"license": "MIT"
},
"node_modules/@types/rbush": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
"license": "MIT"
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/geotiff": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-2.1.3.tgz",
"integrity": "sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==",
"license": "MIT",
"dependencies": {
"@petamoriken/float16": "^3.4.7",
"lerc": "^3.0.0",
"pako": "^2.0.4",
"parse-headers": "^2.0.2",
"quick-lru": "^6.1.1",
"web-worker": "^1.2.0",
"xml-utils": "^1.0.2",
"zstddec": "^0.1.0"
},
"engines": {
"node": ">=10.19"
}
},
"node_modules/lerc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
"license": "Apache-2.0"
},
"node_modules/ol": {
"version": "10.6.1",
"resolved": "https://registry.npmjs.org/ol/-/ol-10.6.1.tgz",
"integrity": "sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg==",
"license": "BSD-2-Clause",
"dependencies": {
"@types/rbush": "4.0.0",
"earcut": "^3.0.0",
"geotiff": "^2.1.3",
"pbf": "4.0.1",
"rbush": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/openlayers"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parse-headers": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/rbush": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
"license": "MIT",
"dependencies": {
"quickselect": "^3.0.0"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
"license": "Apache-2.0"
},
"node_modules/xml-utils": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
"license": "CC0-1.0"
},
"node_modules/zstddec": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz",
"integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==",
"license": "MIT AND BSD-3-Clause"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "xprov-map-visualizer",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ol": "10.6.1"
}
}
+207
View File
@@ -0,0 +1,207 @@
const http = require("node:http");
const https = require("node:https");
const fs = require("node:fs");
const path = require("node:path");
const { URL } = require("node:url");
const HOST = process.env.HOST || "0.0.0.0";
const PORT = Number(process.env.PORT || 4173);
const ROOT = __dirname;
const DEFAULT_WMS_ENDPOINT = "https://xpro-viz.jfig.net/geoserver/xpro/wms";
const WMS_ENDPOINT = normalizeEndpoint(process.env.WMS_ENDPOINT || DEFAULT_WMS_ENDPOINT);
const ALLOWED_WMS_ENDPOINTS = new Set(
`${process.env.ALLOWED_WMS_ENDPOINTS || ""},${WMS_ENDPOINT}`
.split(",")
.map((endpoint) => normalizeEndpoint(endpoint.trim()))
.filter(Boolean),
);
const GEOSERVER_AUTH_HEADER = buildGeoserverAuthHeader();
const VENDOR_FILES = {
"/vendor/ol.css": path.join(ROOT, "node_modules", "ol", "ol.css"),
"/vendor/ol.js": path.join(ROOT, "node_modules", "ol", "dist", "ol.js"),
};
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
};
const server = http.createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
if (requestUrl.pathname === "/api/capabilities") {
await proxyCapabilities(req, res, requestUrl);
return;
}
if (requestUrl.pathname === "/api/wms") {
await proxyWms(req, res, requestUrl);
return;
}
if (VENDOR_FILES[requestUrl.pathname]) {
serveFile(VENDOR_FILES[requestUrl.pathname], res);
return;
}
serveStatic(requestUrl.pathname, res);
} catch (error) {
if (!error.statusCode || error.statusCode >= 500) {
console.error(error);
}
json(res, error.statusCode || 500, {
error: error.statusCode ? error.message : "Internal server error",
});
}
});
server.listen(PORT, HOST, () => {
console.log(`XPro Map Visualizer running at http://${HOST}:${PORT}`);
});
async function proxyCapabilities(req, res, requestUrl) {
const upstream = buildUpstreamUrl(requestUrl.searchParams.get("url"));
upstream.searchParams.set("service", "WMS");
upstream.searchParams.set("request", "GetCapabilities");
const upstreamResponse = await fetchUpstream(upstream);
res.writeHead(upstreamResponse.statusCode, {
"Content-Type": upstreamResponse.contentType || "text/xml; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(upstreamResponse.body);
}
async function proxyWms(req, res, requestUrl) {
const upstream = buildUpstreamUrl(requestUrl.searchParams.get("url"));
requestUrl.searchParams.forEach((value, key) => {
if (key !== "url") {
upstream.searchParams.set(key, value);
}
});
const upstreamResponse = await fetchUpstream(upstream);
res.writeHead(upstreamResponse.statusCode, {
"Content-Type": upstreamResponse.contentType || "application/octet-stream",
"Cache-Control": "no-store",
});
res.end(upstreamResponse.body);
}
function buildUpstreamUrl(rawUrl) {
const endpoint = normalizeEndpoint(rawUrl || WMS_ENDPOINT);
if (!ALLOWED_WMS_ENDPOINTS.has(endpoint)) {
throw httpError(403, "WMS endpoint is not allowed.");
}
const upstream = new URL(endpoint);
if (!["http:", "https:"].includes(upstream.protocol)) {
throw httpError(400, "Unsupported WMS endpoint protocol.");
}
return upstream;
}
function normalizeEndpoint(rawUrl) {
if (!rawUrl) {
return "";
}
try {
const endpoint = new URL(rawUrl);
endpoint.hash = "";
endpoint.search = "";
endpoint.pathname = endpoint.pathname.replace(/\/+$/, "");
return endpoint.toString();
} catch {
throw httpError(400, "Invalid WMS endpoint URL.");
}
}
function fetchUpstream(url) {
const client = url.protocol === "https:" ? https : http;
return new Promise((resolve, reject) => {
const upstreamReq = client.request(
url,
{
method: "GET",
headers: GEOSERVER_AUTH_HEADER ? { Authorization: GEOSERVER_AUTH_HEADER } : {},
},
(upstreamRes) => {
const chunks = [];
upstreamRes.on("data", (chunk) => chunks.push(chunk));
upstreamRes.on("end", () => {
resolve({
statusCode: upstreamRes.statusCode || 502,
contentType: upstreamRes.headers["content-type"],
body: Buffer.concat(chunks),
});
});
},
);
upstreamReq.on("error", reject);
upstreamReq.end();
});
}
function buildGeoserverAuthHeader() {
if (process.env.GEOSERVER_BASIC_AUTH) {
return process.env.GEOSERVER_BASIC_AUTH.startsWith("Basic ")
? process.env.GEOSERVER_BASIC_AUTH
: `Basic ${process.env.GEOSERVER_BASIC_AUTH}`;
}
if (!process.env.GEOSERVER_USERNAME || !process.env.GEOSERVER_PASSWORD) {
return "";
}
const credentials = `${process.env.GEOSERVER_USERNAME}:${process.env.GEOSERVER_PASSWORD}`;
return `Basic ${Buffer.from(credentials).toString("base64")}`;
}
function serveStatic(requestPath, res) {
const safePath = requestPath === "/" ? "/index.html" : requestPath;
const filePath = path.resolve(ROOT, `.${safePath}`);
const relativePath = path.relative(ROOT, filePath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
json(res, 403, { error: "Forbidden" });
return;
}
serveFile(filePath, res);
}
function serveFile(filePath, res) {
fs.readFile(filePath, (error, data) => {
if (error) {
json(res, 404, { error: "Not found" });
return;
}
const ext = path.extname(filePath);
res.writeHead(200, {
"Content-Type": MIME_TYPES[ext] || "application/octet-stream",
"Cache-Control": "no-store",
});
res.end(data);
});
}
function json(res, statusCode, payload) {
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function httpError(statusCode, message) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
+227
View File
@@ -0,0 +1,227 @@
:root {
color-scheme: light;
--bg: #f3efe3;
--panel: rgba(253, 249, 240, 0.92);
--panel-border: rgba(92, 73, 49, 0.18);
--ink: #1f2a2e;
--muted: #5f6b6f;
--accent: #1f7a62;
--accent-strong: #125441;
--shadow: 0 24px 60px rgba(52, 43, 28, 0.12);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
font-family: "Aptos", "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.55), transparent 32%),
linear-gradient(135deg, #e9dec4 0%, #f7f4ec 52%, #d8eadf 100%);
color: var(--ink);
}
body {
min-height: 100vh;
}
.app-shell {
display: grid;
grid-template-columns: minmax(320px, 380px) 1fr;
min-height: 100vh;
gap: 24px;
padding: 24px;
}
.sidebar,
.map-wrap {
min-height: 0;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
.brand,
.panel {
background: var(--panel);
backdrop-filter: blur(16px);
border: 1px solid var(--panel-border);
border-radius: 24px;
box-shadow: var(--shadow);
}
.brand {
padding: 24px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-family: "Aptos Display", "Segoe UI", sans-serif;
font-size: clamp(2rem, 4vw, 2.7rem);
line-height: 0.98;
}
.panel {
padding: 20px;
}
form.panel {
display: flex;
flex-direction: column;
gap: 14px;
}
label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.95rem;
}
label span {
color: var(--muted);
font-weight: 600;
}
input {
width: 100%;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(73, 84, 79, 0.18);
background: rgba(255, 255, 255, 0.72);
color: var(--ink);
font: inherit;
}
input:focus {
outline: 2px solid rgba(31, 122, 98, 0.18);
border-color: var(--accent);
}
button {
border: 0;
border-radius: 14px;
padding: 13px 16px;
background: linear-gradient(135deg, var(--accent) 0%, #2b9a79 100%);
color: #fffefb;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, opacity 180ms ease;
box-shadow: 0 18px 36px rgba(31, 122, 98, 0.22);
}
button:hover {
transform: translateY(-1px);
}
button:disabled {
opacity: 0.72;
cursor: wait;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.badge {
min-width: 2rem;
padding: 5px 10px;
border-radius: 999px;
background: rgba(31, 122, 98, 0.12);
color: var(--accent-strong);
text-align: center;
font-weight: 700;
}
.status {
margin-top: 12px;
color: var(--muted);
}
.status.error {
color: #9d2e21;
}
.status.success {
color: var(--accent-strong);
}
.layer-list {
margin-top: 16px;
display: grid;
gap: 10px;
}
.layer-list.empty {
color: var(--muted);
line-height: 1.5;
}
.layer-item {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.66);
border: 1px solid rgba(73, 84, 79, 0.12);
}
.layer-item input {
width: auto;
margin-top: 3px;
}
.layer-meta {
display: grid;
gap: 3px;
}
.layer-title {
font-weight: 700;
}
.layer-name {
color: var(--muted);
font-size: 0.9rem;
}
.map-wrap {
position: relative;
overflow: hidden;
border-radius: 32px;
box-shadow: var(--shadow);
min-height: 70vh;
}
#map {
position: absolute;
inset: 0;
}
@media (max-width: 920px) {
.app-shell {
grid-template-columns: 1fr;
}
.map-wrap {
min-height: 62vh;
}
}