Initial XPro map visualizer
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
Dockerfile
|
||||
README.md
|
||||
npm-debug.log
|
||||
node_modules
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
+20
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
Generated
+169
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "xprov-map-visualizer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ol": "10.6.1"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user