feat(map): introduce geodesy functions, map link utilities, and tile network management for updated mapping capabilities

This commit is contained in:
Ivan
2026-04-23 02:28:02 -05:00
parent de74a738b4
commit 964ef9b274
12 changed files with 1253 additions and 0 deletions
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: 0BSD
/** GeoJSON / feature properties for icons and strokes (namespaced for export fidelity). */
export const MCX_ICON_HREF = "mcx_icon_href";
export const MCX_ICON_DATA_URL = "mcx_icon_data_url";
export const MCX_ICON_SCALE = "mcx_icon_scale";
export const MCX_ICON_ANCHOR_X = "mcx_icon_anchor_x";
export const MCX_ICON_ANCHOR_Y = "mcx_icon_anchor_y";
export const MCX_STROKE_COLOR = "mcx_stroke_color";
export const MCX_STROKE_WIDTH = "mcx_stroke_width";
export const MCX_FILL_COLOR = "mcx_fill_color";
export const MCX_FILL_OPACITY = "mcx_fill_opacity";
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: 0BSD
import GeoJSON from "ol/format/GeoJSON";
import { normalizeFeatureMetadataProps } from "./metadataUtils.js";
import { copyStyleMetadataToProperties, styleFromMcxProperties } from "./styleFromProperties.js";
/**
* @param {string} text
* @param {import("ol/proj").ProjectionLike} featureProjection
* @returns {import("ol/Feature").default[]}
*/
export function readGeoJsonToFeatures(text, featureProjection) {
const format = new GeoJSON();
const features = format.readFeatures(text, {
dataProjection: "EPSG:4326",
featureProjection,
});
for (const f of features) {
normalizeFeatureMetadataProps(f);
if (!f.getStyle()) {
const s = styleFromMcxProperties(f);
if (s) {
f.setStyle(s);
}
}
}
return features;
}
/**
* @param {import("ol/Feature").default[]} features
* @param {import("ol/proj").ProjectionLike} featureProjection
* @returns {string}
*/
export function writeFeaturesToGeoJson(features, featureProjection) {
const format = new GeoJSON();
for (const f of features) {
let st = f.getStyle();
if (typeof st === "function") {
st = null;
}
if (st) {
copyStyleMetadataToProperties(st, f);
} else {
const built = styleFromMcxProperties(f);
if (built) {
copyStyleMetadataToProperties(built, f);
}
}
}
return format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection,
decimals: 7,
});
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: 0BSD
import KML from "ol/format/KML";
import { normalizeFeatureMetadataProps } from "./metadataUtils.js";
import { normalizeKmlImportedFeatures, ensureOlStylesForKmlExport } from "./styleFromProperties.js";
/**
* @param {string} text
* @param {import("ol/proj").ProjectionLike} featureProjection
* @returns {import("ol/Feature").default[]}
*/
export function readKmlToFeatures(text, featureProjection) {
const format = new KML({
extractStyles: true,
showNetworkLinks: false,
showPointNames: false,
});
const features = format.readFeatures(text, {
dataProjection: "EPSG:4326",
featureProjection,
});
normalizeKmlImportedFeatures(features);
for (const f of features) {
normalizeFeatureMetadataProps(f);
}
return features;
}
/**
* @param {import("ol/Feature").default[]} features
* @param {import("ol/proj").ProjectionLike} featureProjection
* @returns {string}
*/
export function writeFeaturesToKml(features, featureProjection) {
const format = new KML();
ensureOlStylesForKmlExport(features);
return format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection,
});
}
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: 0BSD
import JSZip from "jszip";
import { readKmlToFeatures, writeFeaturesToKml } from "./kmlCodec.js";
/**
* @param {Uint8Array} u8
* @returns {string}
*/
function uint8ToBase64(u8) {
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < u8.length; i += CHUNK) {
binary += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/**
* @param {string} pathInZip
* @returns {string}
*/
function guessMimeFromPath(pathInZip) {
const ext = pathInZip.split(".").pop().toLowerCase();
if (ext === "png") {
return "image/png";
}
if (ext === "jpg" || ext === "jpeg") {
return "image/jpeg";
}
if (ext === "gif") {
return "image/gif";
}
if (ext === "webp") {
return "image/webp";
}
if (ext === "svg") {
return "image/svg+xml";
}
return "application/octet-stream";
}
/**
* @param {string} mime
* @returns {string}
*/
function extFromMime(mime) {
const m = String(mime || "").toLowerCase();
if (m.includes("png")) {
return "png";
}
if (m.includes("jpeg") || m.includes("jpg")) {
return "jpg";
}
if (m.includes("gif")) {
return "gif";
}
if (m.includes("webp")) {
return "webp";
}
if (m.includes("svg")) {
return "svg";
}
return "bin";
}
/**
* @param {string} kmlPathInZip forward slashes, e.g. "folder/doc.kml"
* @param {string} href
* @returns {string|null} resolved path inside zip or null if external / invalid
*/
export function resolveHrefToZipPath(kmlPathInZip, href) {
const h = String(href).trim();
if (!h || /^(https?:|data:|file:|\/\/)/i.test(h)) {
return null;
}
const base = kmlPathInZip.includes("/") ? kmlPathInZip.slice(0, kmlPathInZip.lastIndexOf("/") + 1) : "";
const combined = (base + h).replace(/\\/g, "/");
const segments = combined.split("/").filter((s) => s.length && s !== ".");
const out = [];
for (const s of segments) {
if (s === "..") {
if (!out.length) {
return null;
}
out.pop();
} else {
out.push(s);
}
}
return out.join("/");
}
/**
* @param {import("jszip").default} zip
* @returns {string|null}
*/
function findKmlEntryName(zip) {
const names = Object.keys(zip.files).filter((n) => !zip.files[n].dir);
const doc = names.find((n) => n.replace(/\\/g, "/").toLowerCase() === "doc.kml");
if (doc) {
return doc.replace(/\\/g, "/");
}
const kmls = names.map((n) => n.replace(/\\/g, "/")).filter((n) => n.toLowerCase().endsWith(".kml"));
if (!kmls.length) {
return null;
}
kmls.sort((a, b) => a.length - b.length);
return kmls[0];
}
/**
* @param {import("jszip").default} zip
* @param {string} zipPath
* @returns {import("jszip").JSZipObject|null}
*/
function zipFileInsensitive(zip, zipPath) {
const norm = zipPath.replace(/\\/g, "/");
let f = zip.file(norm);
if (f) {
return f;
}
const want = norm.toLowerCase();
const keys = Object.keys(zip.files);
const hit = keys.find((k) => !zip.files[k].dir && k.replace(/\\/g, "/").toLowerCase() === want);
return hit ? zip.file(hit) : null;
}
/**
* Embed zip-local icon paths as data: URIs so blob: URLs are not required (merge-safe).
* @param {import("jszip").default} zip
* @param {string} kmlText
* @param {string} kmlEntryName
* @returns {Promise<string>}
*/
async function rewriteKmlLocalHrefsToDataUrls(zip, kmlText, kmlEntryName) {
const hrefRe = /<href>\s*([^<]+?)\s*<\/href>/gi;
const matches = [...kmlText.matchAll(hrefRe)];
const rawToData = new Map();
for (const m of matches) {
const raw = m[1].trim();
if (rawToData.has(raw)) {
continue;
}
if (/^(https?:|data:)/i.test(raw)) {
continue;
}
const zipPath = resolveHrefToZipPath(kmlEntryName, raw);
if (!zipPath) {
continue;
}
const entry = zipFileInsensitive(zip, zipPath);
if (!entry) {
continue;
}
const ab = await entry.async("arraybuffer");
const mime = guessMimeFromPath(zipPath);
const b64 = uint8ToBase64(new Uint8Array(ab));
rawToData.set(raw, `data:${mime};base64,${b64}`);
}
return kmlText.replace(hrefRe, (full, inner) => {
const raw = inner.trim();
const data = rawToData.get(raw);
return data ? `<href>${data}</href>` : full;
});
}
/**
* @param {ArrayBuffer} arrayBuffer
* @param {import("ol/proj").ProjectionLike} featureProjection
* @returns {Promise<import("ol/Feature").default[]>}
*/
export async function readKmzToFeatures(arrayBuffer, featureProjection) {
const zip = await JSZip.loadAsync(arrayBuffer);
const kmlName = findKmlEntryName(zip);
if (!kmlName) {
throw new Error("KMZ has no KML document");
}
const entry = zip.file(kmlName);
if (!entry) {
throw new Error("KMZ KML entry missing");
}
let kmlText = await entry.async("string");
kmlText = await rewriteKmlLocalHrefsToDataUrls(zip, kmlText, kmlName);
return readKmlToFeatures(kmlText, featureProjection);
}
/**
* @param {import("ol/Feature").default[]} features
* @param {import("ol/proj").ProjectionLike} featureProjection
* @returns {Promise<Blob>}
*/
export async function writeFeaturesToKmzBlob(features, featureProjection) {
let kml = writeFeaturesToKml(features, featureProjection);
const zip = new JSZip();
let n = 0;
const dataUriRe = /<href>\s*(data:([^;]+);base64,([^<\s]+))\s*<\/href>/gi;
kml = kml.replace(dataUriRe, (full, _dataUri, mime, b64) => {
const ext = extFromMime(mime);
const path = `files/mcx-embedded-${n++}.${ext}`;
let bin;
try {
const binary = atob(String(b64).trim());
bin = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bin[i] = binary.charCodeAt(i);
}
} catch {
return full;
}
zip.file(path, bin);
return `<href>${path}</href>`;
});
zip.file("doc.kml", kml);
return zip.generateAsync({ type: "blob", compression: "DEFLATE" });
}
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: 0BSD
import { getCenter } from "ol/extent";
import {
MCX_FILL_COLOR,
MCX_FILL_OPACITY,
MCX_ICON_ANCHOR_X,
MCX_ICON_ANCHOR_Y,
MCX_ICON_DATA_URL,
MCX_ICON_HREF,
MCX_ICON_SCALE,
MCX_STROKE_COLOR,
MCX_STROKE_WIDTH,
} from "./constants.js";
const SKIP_EXTENDED = new Set([
"geometry",
"type",
"note",
"telemetry",
"discovered",
"cluster",
"peer",
"segmentKind",
"bearingMetrics",
"_measureOverlay",
MCX_ICON_DATA_URL,
MCX_ICON_HREF,
MCX_ICON_SCALE,
MCX_ICON_ANCHOR_X,
MCX_ICON_ANCHOR_Y,
MCX_STROKE_COLOR,
MCX_STROKE_WIDTH,
MCX_FILL_COLOR,
MCX_FILL_OPACITY,
"marker-color",
"marker-size",
"stroke",
"stroke-width",
"fill",
"fill-opacity",
]);
/**
* Normalize KML-style Name/Description onto lowercase keys for export.
* @param {import("ol/Feature").default} feature
*/
export function normalizeFeatureMetadataProps(feature) {
if (!feature) {
return;
}
const n = feature.get("name");
const N = feature.get("Name");
if ((n == null || n === "") && N != null && N !== "") {
feature.set("name", N);
}
const d = feature.get("description");
const D = feature.get("Description");
if ((d == null || d === "") && D != null && D !== "") {
feature.set("description", D);
}
const t = feature.get("title");
const nameAfterKml = feature.get("name");
if ((nameAfterKml == null || nameAfterKml === "") && t != null && t !== "") {
feature.set("name", typeof t === "string" ? t : String(t));
}
}
/**
* @param {import("ol/Feature").default} feature
* @returns {import("ol/coordinate").Coordinate|null}
*/
export function getFeatureAnchorCoordinate(feature) {
const g = feature.getGeometry();
if (!g) {
return null;
}
const t = g.getType();
if (t === "Point") {
return /** @type {import("ol/geom/Point").default} */ (g).getCoordinates();
}
if (t === "MultiPoint") {
return /** @type {import("ol/geom/MultiPoint").default} */ (g).getPoint(0).getCoordinates();
}
if (t === "Polygon") {
return /** @type {import("ol/geom/Polygon").default} */ (g).getInteriorPoint().getCoordinates();
}
if (t === "MultiPolygon") {
const mp = /** @type {import("ol/geom/MultiPolygon").default} */ (g);
return mp.getPolygon(0).getInteriorPoint().getCoordinates();
}
if (t === "LineString") {
const c = /** @type {import("ol/geom/LineString").default} */ (g).getCoordinates();
if (!c.length) {
return null;
}
return c[Math.floor(c.length / 2)];
}
if (t === "MultiLineString") {
const ml = /** @type {import("ol/geom/MultiLineString").default} */ (g);
const line = ml.getLineString(0);
const c = line.getCoordinates();
if (!c.length) {
return null;
}
return c[Math.floor(c.length / 2)];
}
return getCenter(g.getExtent());
}
function looksLikeHtml(s) {
return /<\/?[a-z][\s\S]*>/i.test(s);
}
/**
* @param {import("ol/Feature").default} feature
* @returns {{ name: string, description: string, descriptionIsHtml: boolean, iconSrc: string|null, extended: { key: string, value: string }[] }|null}
*/
export function getDrawFeatureMetadataPayload(feature) {
if (!feature) {
return null;
}
normalizeFeatureMetadataProps(feature);
const props = feature.getProperties();
if (props.type === "note") {
return null;
}
const name = String(props.name ?? "").trim();
const rawDesc = props.description;
const description = rawDesc == null ? "" : typeof rawDesc === "string" ? rawDesc : String(rawDesc);
const iconSrc = props[MCX_ICON_DATA_URL] || props[MCX_ICON_HREF] || null;
const extended = [];
for (const [k, v] of Object.entries(props)) {
if (k === "geometry" || k.startsWith("_")) {
continue;
}
if (SKIP_EXTENDED.has(k) || k.startsWith("mcx_")) {
continue;
}
if (k === "name" || k === "Name" || k === "description" || k === "Description") {
continue;
}
let vs;
if (v == null) {
vs = "";
} else if (typeof v === "object") {
try {
vs = JSON.stringify(v);
} catch {
vs = String(v);
}
} else {
vs = String(v);
}
if (vs.length > 400) {
vs = `${vs.slice(0, 400)}`;
}
extended.push({ key: k, value: vs });
}
extended.sort((a, b) => a.key.localeCompare(b.key));
if (!name && !description.trim() && !extended.length && !iconSrc) {
const geom = feature.getGeometry();
const geomType = geom ? geom.getType() : null;
if (geomType) {
return {
name: "",
description: "",
descriptionIsHtml: false,
iconSrc: null,
extended: [{ key: "geometry_type", value: geomType }],
};
}
return null;
}
return {
name,
description,
descriptionIsHtml: Boolean(description.trim() && looksLikeHtml(description)),
iconSrc: iconSrc ? String(iconSrc) : null,
extended,
};
}
@@ -0,0 +1,262 @@
// SPDX-License-Identifier: 0BSD
import { Circle as CircleStyle, Fill, Icon, Stroke, Style } from "ol/style";
import LineString from "ol/geom/LineString";
import {
MCX_FILL_COLOR,
MCX_FILL_OPACITY,
MCX_ICON_ANCHOR_X,
MCX_ICON_ANCHOR_Y,
MCX_ICON_DATA_URL,
MCX_ICON_HREF,
MCX_ICON_SCALE,
MCX_STROKE_COLOR,
MCX_STROKE_WIDTH,
} from "./constants.js";
const SIMPLE_MARKER_COLOR = "marker-color";
const SIMPLE_MARKER_SIZE_KEY = "marker-size";
const ICON_BASE_CSS_PX = 32;
const ICON_WIDTH_MIN_PX = 8;
const ICON_WIDTH_MAX_PX = 40;
function num(v, fallback) {
const n = typeof v === "number" ? v : parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function hexToRgba(hex, alpha = 1) {
if (!hex || typeof hex !== "string") {
return `rgba(59,130,246,${alpha})`;
}
let h = hex.trim();
if (h.startsWith("#")) {
h = h.slice(1);
}
if (h.length === 3) {
h = h
.split("")
.map((c) => c + c)
.join("");
}
if (h.length !== 6) {
return `rgba(59,130,246,${alpha})`;
}
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
function circleRadiusFromSimpleSize(markerSize) {
const s = String(markerSize || "medium").toLowerCase();
if (s === "small") {
return 5;
}
if (s === "large") {
return 11;
}
return 8;
}
/**
* Build an OpenLayers Style from MeshChatX / simplestyle-ish feature properties.
* Used when the feature has no per-feature style (e.g. GeoJSON import).
* @param {import("ol/Feature").default} feature
* @returns {import("ol/style/Style").default|null}
*/
/**
* Replace OL KML icon styles with a capped MCX icon style when metadata is present.
* Avoids full-resolution bitmaps when scale was captured before the image finished loading.
* @param {import("ol/Feature").default} feature
*/
export function applyCappedMcxIconStyleIfNeeded(feature) {
const g = feature.getGeometry();
if (!g) {
return;
}
const t = g.getType();
if (t !== "Point" && t !== "MultiPoint") {
return;
}
const p = feature.getProperties();
if (!(p[MCX_ICON_DATA_URL] || p[MCX_ICON_HREF])) {
return;
}
const built = styleFromMcxProperties(feature);
if (built) {
feature.setStyle(built);
}
}
export function styleFromMcxProperties(feature) {
const geom = feature.getGeometry();
if (!geom) {
return null;
}
const p = feature.getProperties();
const type = geom.getType();
const iconSrc = p[MCX_ICON_DATA_URL] || p[MCX_ICON_HREF];
if (iconSrc && (type === "Point" || type === "MultiPoint")) {
const factor = num(p[MCX_ICON_SCALE], 1);
const widthPx = Math.round(Math.min(ICON_WIDTH_MAX_PX, Math.max(ICON_WIDTH_MIN_PX, ICON_BASE_CSS_PX * factor)));
const ax = num(p[MCX_ICON_ANCHOR_X], 0.5);
const ay = num(p[MCX_ICON_ANCHOR_Y], 1);
const isData = String(iconSrc).startsWith("data:");
return new Style({
image: new Icon({
src: iconSrc,
width: widthPx,
anchor: [ax, ay],
crossOrigin: isData ? undefined : "anonymous",
}),
});
}
if (type === "Point" || type === "MultiPoint") {
const mc = p[SIMPLE_MARKER_COLOR] || p["marker-color"];
if (mc) {
const r = circleRadiusFromSimpleSize(p[SIMPLE_MARKER_SIZE_KEY]);
return new Style({
image: new CircleStyle({
radius: r,
fill: new Fill({ color: hexToRgba(mc, 0.85) }),
stroke: new Stroke({ color: "#1f2937", width: 1 }),
}),
});
}
}
const strokeRaw = p[MCX_STROKE_COLOR] ?? p.stroke ?? "#2563eb";
const strokeWidth = num(p[MCX_STROKE_WIDTH] ?? p["stroke-width"], 2);
const fillRaw = p[MCX_FILL_COLOR] ?? p.fill;
const fillOpacity = num(p[MCX_FILL_OPACITY] ?? p["fill-opacity"], fillRaw ? 0.35 : 0);
const stroke = new Stroke({
color: /** @type {import("ol/color").Color|string} */ (strokeRaw),
width: strokeWidth,
});
if (type === "LineString" || type === "MultiLineString") {
return new Style({ stroke });
}
if (type === "Polygon" || type === "MultiPolygon") {
let fill;
if (fillRaw) {
fill =
typeof fillRaw === "string"
? new Fill({
color: hexToRgba(fillRaw, fillOpacity > 0 ? fillOpacity : 0.35),
})
: new Fill({ color: /** @type {import("ol/color").Color} */ (fillRaw) });
} else {
fill = new Fill({ color: "rgba(59, 130, 246, 0.2)" });
}
return new Style({ stroke, fill });
}
if (type === "Circle") {
const g = /** @type {import("ol/geom/Circle").default} */ (geom);
const center = g.getCenter();
const edge = [center[0] + g.getRadius(), center[1]];
const line = new LineString([center, edge]);
return new Style({
stroke,
geometry: line,
});
}
return null;
}
/**
* Copy icon/stroke metadata from an OpenLayers Style into feature properties for GeoJSON export.
* @param {import("ol/style/Style").default} style
* @param {import("ol/Feature").default} feature
*/
export function copyStyleMetadataToProperties(style, feature) {
if (!style || !feature) {
return;
}
const styles = Array.isArray(style) ? style : [style];
for (const st of styles) {
if (!st || typeof st.getImage !== "function") {
continue;
}
const img = st.getImage();
if (img && typeof img.getSrc === "function") {
const src = img.getSrc();
if (src) {
if (String(src).startsWith("data:")) {
feature.set(MCX_ICON_DATA_URL, src);
} else {
feature.set(MCX_ICON_HREF, src);
}
const sc = img.getScale();
if (sc != null) {
feature.set(MCX_ICON_SCALE, sc);
}
const anchor = img.getAnchor && img.getAnchor();
if (anchor && anchor.length >= 2) {
feature.set(MCX_ICON_ANCHOR_X, anchor[0]);
feature.set(MCX_ICON_ANCHOR_Y, anchor[1]);
}
}
}
if (typeof st.getStroke === "function") {
const s = st.getStroke();
if (s && s.getColor()) {
feature.set(MCX_STROKE_COLOR, s.getColor());
feature.set(MCX_STROKE_WIDTH, s.getWidth());
}
}
if (typeof st.getFill === "function") {
const f = st.getFill();
if (f && f.getColor()) {
feature.set(MCX_FILL_COLOR, f.getColor());
}
}
}
}
/**
* After KML import, mirror style into mcx_* props so GeoJSON export keeps icons.
* @param {import("ol/Feature").default[]} features
*/
export function normalizeKmlImportedFeatures(features) {
for (const f of features) {
let st = f.getStyle();
if (typeof st === "function") {
st = st(f);
}
const list = st == null ? [] : Array.isArray(st) ? st : [st];
for (const s of list) {
copyStyleMetadataToProperties(s, f);
}
applyCappedMcxIconStyleIfNeeded(f);
}
}
/**
* Ensure each feature has an OL style for KML export when only properties were set.
* Mutates features (sets style).
* @param {import("ol/Feature").default[]} features
*/
export function ensureOlStylesForKmlExport(features) {
for (const f of features) {
let st = f.getStyle();
if (typeof st === "function") {
st = null;
}
if (st != null) {
continue;
}
const built = styleFromMcxProperties(f);
if (built) {
f.setStyle(built);
}
}
}
+150
View File
@@ -0,0 +1,150 @@
// SPDX-License-Identifier: 0BSD
import { DEFAULT_RADIUS, getDistance } from "ol/sphere";
/**
* @param {number} lon1
* @param {number} lat1
* @param {number} lon2
* @param {number} lat2
* @returns {number} Initial (forward) azimuth in degrees [0, 360), spherical model.
*/
export function sphericalInitialBearingDeg(lon1, lat1, lon2, lat2) {
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const θ = Math.atan2(y, x);
return ((((θ * 180) / Math.PI) % 360) + 360) % 360;
}
/**
* Rhumb-line distance and constant bearing on the sphere (mean Earth radius).
* @param {number} lon1
* @param {number} lat1
* @param {number} lon2
* @param {number} lat2
* @param {number} [radius]
* @returns {{ distanceMeters: number, bearingDeg: number }}
*/
export function rhumbLineMetrics(lon1, lat1, lon2, lat2, radius = DEFAULT_RADIUS) {
const R = radius;
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
let Δλ = ((lon2 - lon1) * Math.PI) / 180;
if (Δλ > Math.PI) {
Δλ -= 2 * Math.PI;
}
if (Δλ < -Math.PI) {
Δλ += 2 * Math.PI;
}
const Δφ = φ2 - φ1;
const Δψ = Math.log(Math.tan(Math.PI / 4 + φ2 / 2) / Math.tan(Math.PI / 4 + φ1 / 2));
const q = Math.abs(Δψ) > 1e-12 ? Δφ / Δψ : Math.cos(φ1);
const dist = Math.sqrt(Δφ * Δφ + q * q * Δλ * Δλ) * R;
const bearingDeg = ((((Math.atan2(Δλ, Δψ) * 180) / Math.PI) % 360) + 360) % 360;
return { distanceMeters: dist, bearingDeg };
}
/**
* Great-circle (geodesic on sphere) distance matches OpenLayers {@link import("ol/sphere").getLength}
* for a two-point line in WGS84.
*
* @param {number} lon1
* @param {number} lat1
* @param {number} lon2
* @param {number} lat2
* @returns {{
* geodesicMeters: number,
* forwardAzimuthDeg: number,
* backAzimuthDeg: number,
* rhumbMeters: number,
* rhumbBearingDeg: number,
* rhumbBackBearingDeg: number,
* }}
*/
export function computeSegmentMetrics(lon1, lat1, lon2, lat2) {
const a = [lon1, lat1];
const b = [lon2, lat2];
const geodesicMeters = getDistance(a, b);
const forwardAzimuthDeg = sphericalInitialBearingDeg(lon1, lat1, lon2, lat2);
const backAzimuthDeg = sphericalInitialBearingDeg(lon2, lat2, lon1, lat1);
const rh = rhumbLineMetrics(lon1, lat1, lon2, lat2);
const rhumbBackBearingDeg = (rh.bearingDeg + 180) % 360;
return {
geodesicMeters,
forwardAzimuthDeg,
backAzimuthDeg,
rhumbMeters: rh.distanceMeters,
rhumbBearingDeg: rh.bearingDeg,
rhumbBackBearingDeg,
};
}
/**
* @param {number} meters
* @returns {{ metric: string, imperial: string }}
*/
export function formatLengthPairMeters(meters) {
let metric;
let imperial;
if (meters > 100) {
metric = `${Math.round((meters / 1000) * 100) / 100} km`;
} else {
metric = `${Math.round(meters * 100) / 100} m`;
}
const feet = meters * 3.28084;
if (feet > 5280) {
imperial = `${Math.round(meters * 0.000621371 * 100) / 100} mi`;
} else {
imperial = `${Math.round(feet * 100) / 100} ft`;
}
return { metric, imperial };
}
/**
* @param {ReturnType<typeof computeSegmentMetrics>} metrics
* @param {(key: string) => string} t i18n
* @returns {string}
*/
export function buildBearingOverlayHtml(metrics, t) {
const geo = formatLengthPairMeters(metrics.geodesicMeters);
const rh = formatLengthPairMeters(metrics.rhumbMeters);
const fd = metrics.forwardAzimuthDeg.toFixed(1);
const bd = metrics.backAzimuthDeg.toFixed(1);
const rfd = metrics.rhumbBearingDeg.toFixed(1);
const rbd = metrics.rhumbBackBearingDeg.toFixed(1);
return (
`<div class="text-left space-y-0.5">` +
`<div class="font-semibold text-gray-900 dark:text-zinc-100">${escapeHtml(t("map.bearing_geodesic"))}</div>` +
`<div>${escapeHtml(geo.metric)} <span class="text-[10px] opacity-80">(${escapeHtml(geo.imperial)})</span></div>` +
`<div>${escapeHtml(t("map.bearing_forward"))}: ${escapeHtml(fd)}°</div>` +
`<div>${escapeHtml(t("map.bearing_back"))}: ${escapeHtml(bd)}°</div>` +
`<div class="mt-1 font-semibold text-gray-900 dark:text-zinc-100">${escapeHtml(t("map.bearing_rhumb"))}</div>` +
`<div>${escapeHtml(rh.metric)} <span class="text-[10px] opacity-80">(${escapeHtml(rh.imperial)})</span></div>` +
`<div>${escapeHtml(t("map.bearing_rhumb_line"))}: ${escapeHtml(rfd)}° / ${escapeHtml(rbd)}°</div>` +
`</div>`
);
}
/**
* @param {ReturnType<typeof computeSegmentMetrics>} metrics
* @param {(key: string) => string} t
* @returns {string}
*/
export function buildBearingLiveTooltipHtml(metrics, t) {
const geo = formatLengthPairMeters(metrics.geodesicMeters);
const fd = metrics.forwardAzimuthDeg.toFixed(1);
const rd = metrics.rhumbBearingDeg.toFixed(1);
return (
`<span class="font-semibold">${escapeHtml(t("map.bearing_geodesic"))}</span> ` +
`${escapeHtml(geo.metric)}<br/>` +
`<span class="text-[10px] opacity-90">${escapeHtml(t("map.bearing_forward"))}: ${escapeHtml(fd)}° · ` +
`${escapeHtml(t("map.bearing_rhumb_line"))}: ${escapeHtml(rd)}°</span>`
);
}
function escapeHtml(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: 0BSD
/**
* Offline-friendly map deep links: meshchatx://map?lat=&lon=&z=&layers=&label=
* (meshchat://map is accepted as an alias.)
*/
const MAP_URI_IN_TEXT_RE = /(?:meshchatx|meshchat):\/\/map\?[^\s<>]*/gi;
export function findMapUriInContent(text) {
if (!text || typeof text !== "string") {
return null;
}
const matches = text.match(MAP_URI_IN_TEXT_RE);
return matches && matches.length ? matches[0] : null;
}
export function parseMeshchatMapUri(uri) {
if (!uri || typeof uri !== "string") {
return null;
}
const s = uri.trim();
if (!/^(meshchatx|meshchat):\/\/map\b/i.test(s)) {
return null;
}
try {
const u = new URL(s);
const lat = parseFloat(u.searchParams.get("lat") ?? "");
const lon = parseFloat(u.searchParams.get("lon") ?? "");
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return null;
}
const zRaw = u.searchParams.get("z") ?? u.searchParams.get("zoom") ?? "10";
let zoom = Math.round(parseFloat(zRaw));
if (!Number.isFinite(zoom)) {
zoom = 10;
}
zoom = Math.max(0, Math.min(22, zoom));
const layers = (u.searchParams.get("layers") || "").trim();
const label = (u.searchParams.get("label") || "").trim();
return { lat, lon, zoom, layers, label, raw: s };
} catch {
return null;
}
}
export function buildMeshchatMapUri({ lat, lon, zoom, layers = "", label = "" }) {
const z = Math.round(Number(zoom));
const parts = [`lat=${encodeURIComponent(lat)}`, `lon=${encodeURIComponent(lon)}`, `z=${encodeURIComponent(z)}`];
if (layers) {
parts.push(`layers=${encodeURIComponent(layers)}`);
}
if (label) {
parts.push(`label=${encodeURIComponent(label)}`);
}
return `meshchatx://map?${parts.join("&")}`;
}
export function buildWebHashMapUrl({ lat, lon, zoom, layers = "", label = "" }) {
const q = new URLSearchParams();
q.set("lat", String(lat));
q.set("lon", String(lon));
q.set("zoom", String(Math.round(Number(zoom))));
if (layers) {
q.set("layers", layers);
}
if (label) {
q.set("label", label);
}
const base = typeof window !== "undefined" ? `${window.location.origin}${window.location.pathname}` : "";
return `${base}#/map?${q.toString()}`;
}
export function mapLinkKindFromMessage(content, parsed) {
if (content && typeof content === "string" && /MeshChatX\s+map\s+ping/i.test(content)) {
return "ping";
}
if (parsed?.label && /^ping$/i.test(parsed.label)) {
return "ping";
}
return "view";
}
@@ -0,0 +1,71 @@
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const TILE_FETCH_TIMEOUT_MS = 22000;
export const TILE_FETCH_RETRIES = 2;
export const TILE_FETCH_RETRY_BASE_DELAY_MS = 450;
export const NOMINATIM_FETCH_TIMEOUT_MS = 16000;
export const NOMINATIM_FETCH_RETRIES = 1;
export const NOMINATIM_FETCH_RETRY_BASE_DELAY_MS = 500;
export function normalizeHttpBaseUrl(url) {
if (!url || typeof url !== "string") return "";
return url.endsWith("/") ? url.slice(0, -1) : url;
}
export function buildNominatimSearchUrl(nominatimApiUrl, searchQuery, limit = 10) {
const base = normalizeHttpBaseUrl(nominatimApiUrl);
const enc = encodeURIComponent(searchQuery);
return `${base}/search?format=json&q=${enc}&limit=${limit}&addressdetails=1`;
}
export async function fetchWithTimeout(resource, init = {}, timeoutMs = TILE_FETCH_TIMEOUT_MS) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(resource, { ...init, signal: controller.signal });
} finally {
clearTimeout(id);
}
}
export async function fetchTileBlobWithRetry(url, init = {}, options = {}) {
const timeoutMs = options.timeoutMs ?? TILE_FETCH_TIMEOUT_MS;
const retries = options.retries ?? TILE_FETCH_RETRIES;
const baseDelay = options.retryBaseDelayMs ?? TILE_FETCH_RETRY_BASE_DELAY_MS;
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
if (attempt > 0) await delay(baseDelay * attempt);
try {
const response = await fetchWithTimeout(url, init, timeoutMs);
if (!response.ok) {
return { ok: false, status: response.status, error: new Error(`HTTP ${response.status}`) };
}
const blob = await response.blob();
return { ok: true, blob };
} catch (e) {
lastErr = e;
}
}
return { ok: false, error: lastErr };
}
export async function fetchJsonWithRetry(url, init = {}, options = {}) {
const timeoutMs = options.timeoutMs ?? NOMINATIM_FETCH_TIMEOUT_MS;
const retries = options.retries ?? NOMINATIM_FETCH_RETRIES;
const baseDelay = options.retryBaseDelayMs ?? NOMINATIM_FETCH_RETRY_BASE_DELAY_MS;
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
if (attempt > 0) await delay(baseDelay * attempt);
try {
const response = await fetchWithTimeout(url, init, timeoutMs);
if (!response.ok) {
return { ok: false, status: response.status, error: new Error(`HTTP ${response.status}`) };
}
return { ok: true, response };
} catch (e) {
lastErr = e;
}
}
return { ok: false, error: lastErr };
}
@@ -0,0 +1,3 @@
import "@fontsource/noto-sans/400.css";
import "@fontsource/noto-sans/400-italic.css";
import "@fontsource/noto-sans/700.css";
@@ -0,0 +1,155 @@
/**
* Builds SVG paths and layout metadata for a telemetry battery trend chart.
* Uses a wide viewBox so preserveAspectRatio "meet" fills typical modal widths
* without leaving a short square plot.
*/
export const BATTERY_CHART_VIEWBOX = { w: 100, h: 46 };
export const BATTERY_CHART_BOUNDS = {
PL: 10,
PR: 99,
PT: 5,
PB: 38,
};
const { PL, PR, PT, PB } = BATTERY_CHART_BOUNDS;
const VB = BATTERY_CHART_VIEWBOX;
/**
* @param {number} v
* @returns {number}
*/
export function clampBatteryPercent(v) {
const n = Number(v);
if (Number.isNaN(n)) return 0;
return Math.min(100, Math.max(0, n));
}
/**
* @param {{ x: number; y: number }[]} history ascending by x
* @returns {{ x: number; y: number }[]}
*/
function plotPoints(history) {
if (history.length === 0) return [];
const minX = history[0].x;
const maxX = history[history.length - 1].x;
const rangeX = maxX - minX || 1;
return history.map((p) => ({
x: PL + ((p.x - minX) / rangeX) * (PR - PL),
y: PT + (1 - clampBatteryPercent(p.y) / 100) * (PB - PT),
}));
}
/**
* Smooth cubic path through points (Catmull-Rom style control points).
* @param {{ x: number; y: number }[]} pts
* @returns {string}
*/
function smoothLinePath(pts) {
if (pts.length === 0) return "";
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
let d = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = i > 0 ? pts[i - 1] : pts[0];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = i + 2 < pts.length ? pts[i + 2] : p2;
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`;
}
return d;
}
/**
* @param {{ x: number; y: number }[]} history
* @param {string} idSuffix alphanumeric fragment for SVG defs ids
* @returns {null | {
* linePath: string;
* areaPath: string;
* gridLines: { y1: number; y2: number; label: string }[];
* first: { x: number; y: number };
* last: { x: number; y: number };
* plotBottom: number;
* gradientId: string;
* strokeGradientId: string;
* layout: { PL: number; PR: number; PT: number; PB: number; plotBottom: number; minX: number; maxX: number };
* viewBox: string;
* }}
*/
export function buildTelemetryBatteryChartSpec(history, idSuffix = "chart") {
const safe = String(idSuffix).replace(/[^a-zA-Z0-9_-]/g, "") || "chart";
if (history.length < 2) return null;
const minX = history[0].x;
const maxX = history[history.length - 1].x;
const pts = plotPoints(history);
const linePath = smoothLinePath(pts);
const first = pts[0];
const last = pts[pts.length - 1];
const plotBottom = Math.min(PB + 5, VB.h - 1);
const areaPath = `${linePath} L ${last.x} ${plotBottom} L ${first.x} ${plotBottom} Z`;
const gridLines = [100, 75, 50, 25, 0].map((pct) => {
const y = PT + (1 - pct / 100) * (PB - PT);
return { y1: y, y2: y, label: `${pct}` };
});
return {
linePath,
areaPath,
gridLines,
first,
last,
plotBottom,
gradientId: `tb-fill-${safe}`,
strokeGradientId: `tb-stroke-${safe}`,
layout: { PL, PR, PT, PB, plotBottom, minX, maxX },
viewBox: `0 0 ${VB.w} ${VB.h}`,
};
}
/**
* Linear interpolation of charge_percent between samples (by timestamp).
* @param {{ x: number; y: number }[]} history ascending by x
* @param {number} ts unix seconds
* @returns {{ y: number; x: number }}
*/
export function interpolateBatteryByTime(history, ts) {
if (!history.length) return { y: 0, x: ts };
if (history.length === 1) return { y: clampBatteryPercent(history[0].y), x: history[0].x };
if (ts <= history[0].x) return { y: clampBatteryPercent(history[0].y), x: history[0].x };
const hiLast = history[history.length - 1];
if (ts >= hiLast.x) return { y: clampBatteryPercent(hiLast.y), x: hiLast.x };
let lo = 0;
let hi = history.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (history[mid].x <= ts) lo = mid;
else hi = mid;
}
const a = history[lo];
const b = history[hi];
const span = b.x - a.x || 1;
const u = (ts - a.x) / span;
return { y: clampBatteryPercent(a.y + u * (b.y - a.y)), x: ts };
}
/**
* @param {object[]} telemetryChatItems lxmf chat items (telemetry-only ok)
* @returns {{ x: number; y: number }[]}
*/
export function batteryHistoryFromTelemetryItems(telemetryChatItems) {
return telemetryChatItems
.filter((item) => item.lxmf_message?.fields?.telemetry?.battery)
.map((item) => ({
x: item.lxmf_message.timestamp,
y: item.lxmf_message.fields.telemetry.battery.charge_percent,
}))
.sort((a, b) => a.x - b.x);
}
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: 0BSD
class TelephonePcmCaptureProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (!input || input.length === 0) {
return true;
}
const ch0 = input[0];
if (!ch0 || ch0.length === 0) {
return true;
}
const pcm = new Int16Array(ch0.length);
for (let i = 0; i < ch0.length; i += 1) {
const s = ch0[i];
pcm[i] = Math.max(-1, Math.min(1, s)) * 0x7fff;
}
this.port.postMessage(pcm.buffer, [pcm.buffer]);
return true;
}
}
registerProcessor("telephone-pcm-capture", TelephonePcmCaptureProcessor);