From 964ef9b2741f8b6eae47ea6643889ce001f588dc Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 23 Apr 2026 02:28:02 -0500 Subject: [PATCH] feat(map): introduce geodesy functions, map link utilities, and tile network management for updated mapping capabilities --- .../src/frontend/js/mapExchange/constants.js | 12 + .../frontend/js/mapExchange/geoJsonCodec.js | 56 ++++ .../src/frontend/js/mapExchange/kmlCodec.js | 41 +++ .../src/frontend/js/mapExchange/kmzCodec.js | 216 +++++++++++++++ .../frontend/js/mapExchange/metadataUtils.js | 182 ++++++++++++ .../js/mapExchange/styleFromProperties.js | 262 ++++++++++++++++++ meshchatx/src/frontend/js/mapGeodesy.js | 150 ++++++++++ meshchatx/src/frontend/js/mapLinkUtils.js | 82 ++++++ meshchatx/src/frontend/js/mapTileNetwork.js | 71 +++++ .../src/frontend/js/mapVectorWebFonts.js | 3 + .../frontend/js/telemetryBatteryChartSpec.js | 155 +++++++++++ .../js/telephone-pcm-capture.worklet.js | 23 ++ 12 files changed, 1253 insertions(+) create mode 100644 meshchatx/src/frontend/js/mapExchange/constants.js create mode 100644 meshchatx/src/frontend/js/mapExchange/geoJsonCodec.js create mode 100644 meshchatx/src/frontend/js/mapExchange/kmlCodec.js create mode 100644 meshchatx/src/frontend/js/mapExchange/kmzCodec.js create mode 100644 meshchatx/src/frontend/js/mapExchange/metadataUtils.js create mode 100644 meshchatx/src/frontend/js/mapExchange/styleFromProperties.js create mode 100644 meshchatx/src/frontend/js/mapGeodesy.js create mode 100644 meshchatx/src/frontend/js/mapLinkUtils.js create mode 100644 meshchatx/src/frontend/js/mapTileNetwork.js create mode 100644 meshchatx/src/frontend/js/mapVectorWebFonts.js create mode 100644 meshchatx/src/frontend/js/telemetryBatteryChartSpec.js create mode 100644 meshchatx/src/frontend/js/telephone-pcm-capture.worklet.js diff --git a/meshchatx/src/frontend/js/mapExchange/constants.js b/meshchatx/src/frontend/js/mapExchange/constants.js new file mode 100644 index 0000000..5ad77c0 --- /dev/null +++ b/meshchatx/src/frontend/js/mapExchange/constants.js @@ -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"; diff --git a/meshchatx/src/frontend/js/mapExchange/geoJsonCodec.js b/meshchatx/src/frontend/js/mapExchange/geoJsonCodec.js new file mode 100644 index 0000000..1dd6688 --- /dev/null +++ b/meshchatx/src/frontend/js/mapExchange/geoJsonCodec.js @@ -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, + }); +} diff --git a/meshchatx/src/frontend/js/mapExchange/kmlCodec.js b/meshchatx/src/frontend/js/mapExchange/kmlCodec.js new file mode 100644 index 0000000..5096797 --- /dev/null +++ b/meshchatx/src/frontend/js/mapExchange/kmlCodec.js @@ -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, + }); +} diff --git a/meshchatx/src/frontend/js/mapExchange/kmzCodec.js b/meshchatx/src/frontend/js/mapExchange/kmzCodec.js new file mode 100644 index 0000000..bdb22bb --- /dev/null +++ b/meshchatx/src/frontend/js/mapExchange/kmzCodec.js @@ -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} + */ +async function rewriteKmlLocalHrefsToDataUrls(zip, kmlText, kmlEntryName) { + const hrefRe = /\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 ? `${data}` : full; + }); +} + +/** + * @param {ArrayBuffer} arrayBuffer + * @param {import("ol/proj").ProjectionLike} featureProjection + * @returns {Promise} + */ +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} + */ +export async function writeFeaturesToKmzBlob(features, featureProjection) { + let kml = writeFeaturesToKml(features, featureProjection); + const zip = new JSZip(); + let n = 0; + const dataUriRe = /\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 `${path}`; + }); + zip.file("doc.kml", kml); + return zip.generateAsync({ type: "blob", compression: "DEFLATE" }); +} diff --git a/meshchatx/src/frontend/js/mapExchange/metadataUtils.js b/meshchatx/src/frontend/js/mapExchange/metadataUtils.js new file mode 100644 index 0000000..485be91 --- /dev/null +++ b/meshchatx/src/frontend/js/mapExchange/metadataUtils.js @@ -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, + }; +} diff --git a/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js b/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js new file mode 100644 index 0000000..e62a974 --- /dev/null +++ b/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js @@ -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); + } + } +} diff --git a/meshchatx/src/frontend/js/mapGeodesy.js b/meshchatx/src/frontend/js/mapGeodesy.js new file mode 100644 index 0000000..deb998b --- /dev/null +++ b/meshchatx/src/frontend/js/mapGeodesy.js @@ -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} 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 ( + `
` + + `
${escapeHtml(t("map.bearing_geodesic"))}
` + + `
${escapeHtml(geo.metric)} (${escapeHtml(geo.imperial)})
` + + `
${escapeHtml(t("map.bearing_forward"))}: ${escapeHtml(fd)}°
` + + `
${escapeHtml(t("map.bearing_back"))}: ${escapeHtml(bd)}°
` + + `
${escapeHtml(t("map.bearing_rhumb"))}
` + + `
${escapeHtml(rh.metric)} (${escapeHtml(rh.imperial)})
` + + `
${escapeHtml(t("map.bearing_rhumb_line"))}: ${escapeHtml(rfd)}° / ${escapeHtml(rbd)}°
` + + `
` + ); +} + +/** + * @param {ReturnType} 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 ( + `${escapeHtml(t("map.bearing_geodesic"))} ` + + `${escapeHtml(geo.metric)}
` + + `${escapeHtml(t("map.bearing_forward"))}: ${escapeHtml(fd)}° · ` + + `${escapeHtml(t("map.bearing_rhumb_line"))}: ${escapeHtml(rd)}°` + ); +} + +function escapeHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/meshchatx/src/frontend/js/mapLinkUtils.js b/meshchatx/src/frontend/js/mapLinkUtils.js new file mode 100644 index 0000000..e61c56a --- /dev/null +++ b/meshchatx/src/frontend/js/mapLinkUtils.js @@ -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"; +} diff --git a/meshchatx/src/frontend/js/mapTileNetwork.js b/meshchatx/src/frontend/js/mapTileNetwork.js new file mode 100644 index 0000000..eb1468e --- /dev/null +++ b/meshchatx/src/frontend/js/mapTileNetwork.js @@ -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 }; +} diff --git a/meshchatx/src/frontend/js/mapVectorWebFonts.js b/meshchatx/src/frontend/js/mapVectorWebFonts.js new file mode 100644 index 0000000..dac88d2 --- /dev/null +++ b/meshchatx/src/frontend/js/mapVectorWebFonts.js @@ -0,0 +1,3 @@ +import "@fontsource/noto-sans/400.css"; +import "@fontsource/noto-sans/400-italic.css"; +import "@fontsource/noto-sans/700.css"; diff --git a/meshchatx/src/frontend/js/telemetryBatteryChartSpec.js b/meshchatx/src/frontend/js/telemetryBatteryChartSpec.js new file mode 100644 index 0000000..f1d0458 --- /dev/null +++ b/meshchatx/src/frontend/js/telemetryBatteryChartSpec.js @@ -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); +} diff --git a/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.js b/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.js new file mode 100644 index 0000000..3d09654 --- /dev/null +++ b/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.js @@ -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);