diff --git a/CONNECT-DYNAMICS.md b/CONNECT-DYNAMICS.md index 8c12185..f9f53ae 100644 --- a/CONNECT-DYNAMICS.md +++ b/CONNECT-DYNAMICS.md @@ -30,4 +30,10 @@ This is not a closed ISV black box. It is a community-shaped bridge from OSINT-g Early public versioning. Demo data only until security pass. No customer PHI. No live tech GPS in public builds until consent model is locked. -Repo: https://github.com/NukaSoft/gods-eye-view \ No newline at end of file +Repo: https://github.com/NukaSoft/gods-eye-view + +## Layer module (eggbot 2026-09-09) +- src/data/dataverseFs.js — Cesium overlay via local GeoJSON loader +- Sample: public/samples/dataverse-fs-ww-demo.geojson`r +- Env: VITE_DATAVERSE_FS_GEOJSON_URL (adapter URL); default = sample +- Toggle appears with other local data layers (Dynamics Field Service) diff --git a/public/samples/dataverse-fs-ww-demo.geojson b/public/samples/dataverse-fs-ww-demo.geojson new file mode 100644 index 0000000..05fd265 --- /dev/null +++ b/public/samples/dataverse-fs-ww-demo.geojson @@ -0,0 +1,96 @@ +{ + "type": "FeatureCollection", + "name": "dataverse-fs-ww-demo", + "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, + "properties": { + "source": "sample", + "org": "nukasoft.crm.dynamics.com", + "theme": "WeatherWyze", + "disclaimer": "Synthetic demo coordinates for GEV layer spike. Not live GPS. No real customer PHI. Scrub before any public PR." + }, + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-85.6681, 42.9634] }, + "properties": { + "kind": "customer", + "entity": "account", + "id": "00000000-0000-0000-0000-000000000101", + "name": "WW Demo Municipal Utilities", + "status": "active" + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-85.6702, 42.9610] }, + "properties": { + "kind": "site", + "entity": "msdyn_functionallocation", + "id": "00000000-0000-0000-0000-000000000201", + "name": "WW Site — River Pump Station", + "status": "active" + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-85.6655, 42.9662] }, + "properties": { + "kind": "site", + "entity": "msdyn_functionallocation", + "id": "00000000-0000-0000-0000-000000000202", + "name": "WW Site — North Substation", + "status": "active" + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-85.6702, 42.9611] }, + "properties": { + "kind": "asset", + "entity": "msdyn_customerasset", + "id": "00000000-0000-0000-0000-000000000301", + "name": "Pump P-12", + "status": "active", + "locationSource": "site" + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-85.6700, 42.9608] }, + "properties": { + "kind": "workorder", + "entity": "msdyn_workorder", + "id": "00000000-0000-0000-0000-000000000401", + "name": "WW-DEMO-001", + "status": "open", + "priority": "high" + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-85.6658, 42.9660] }, + "properties": { + "kind": "workorder", + "entity": "msdyn_workorder", + "id": "00000000-0000-0000-0000-000000000402", + "name": "WW-DEMO-002", + "status": "open", + "priority": "normal" + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-85.6670, 42.9645] }, + "properties": { + "kind": "resource", + "entity": "bookableresource", + "id": "00000000-0000-0000-0000-000000000501", + "name": "WW Tech A (demo)", + "status": "available", + "locationSource": "start", + "updated": "2026-09-09T18:00:00Z", + "staleSec": 3600 + } + } + ] +} diff --git a/src/data/dataverseFs.js b/src/data/dataverseFs.js new file mode 100644 index 0000000..58ef802 --- /dev/null +++ b/src/data/dataverseFs.js @@ -0,0 +1,93 @@ +/** + * @module dataverseFs + * Dynamics 365 Field Service / Dataverse dispatch layer for God's Eye View. + * Default path: GeoJSON sample or VITE_DATAVERSE_FS_GEOJSON_URL (adapter). + * Browser does not call Dataverse OData by default — secrets stay in the adapter. + */ + +import { createLocalGeoJsonLayer } from './localGeojson.js'; + +export const DATAVERSE_FS_OVERLAY_SOURCE_ID = 'dataverse-fs'; + +const ALLOWED_KINDS = new Set(['customer', 'site', 'asset', 'workorder', 'resource', 'contact']); + +/** + * Normalize a GeoJSON FeatureCollection for the FS layer (unit-testable, no Cesium). + * Drops features without finite lon/lat; keeps kind/id/name when present. + * @param {object} collection + * @returns {{type:string, features:object[]}} + */ +export function normalizeDataverseFsCollection(collection) { + if (!collection || collection.type !== 'FeatureCollection' || !Array.isArray(collection.features)) { + return { type: 'FeatureCollection', features: [] }; + } + const features = []; + for (const f of collection.features) { + const coords = f?.geometry?.coordinates; + if (!Array.isArray(coords) || coords.length < 2) continue; + if (coords[0] == null || coords[1] == null) continue; + const lon = Number(coords[0]); + const lat = Number(coords[1]); + if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; + const props = f.properties && typeof f.properties === 'object' ? { ...f.properties } : {}; + if (props.kind && !ALLOWED_KINDS.has(String(props.kind))) { + // keep unknown kinds out of semantic success counts, but still allow render if coords valid + // for v1 we skip unknown kinds to avoid false greens + continue; + } + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { + kind: props.kind ? String(props.kind) : 'site', + entity: props.entity ? String(props.entity) : 'unknown', + id: props.id != null ? String(props.id) : undefined, + name: props.name != null ? String(props.name) : 'Untitled', + status: props.status, + priority: props.priority, + href: props.href, + updated: props.updated, + staleSec: props.staleSec, + }, + }); + } + return { type: 'FeatureCollection', features }; +} + +function resolveGeoJsonUrl() { + const fromEnv = typeof import.meta !== 'undefined' && import.meta.env + ? import.meta.env.VITE_DATAVERSE_FS_GEOJSON_URL + : undefined; + if (fromEnv && String(fromEnv).trim()) return String(fromEnv).trim(); + return '/samples/dataverse-fs-ww-demo.geojson'; +} + +const dataverseFsLayer = createLocalGeoJsonLayer({ + id: DATAVERSE_FS_OVERLAY_SOURCE_ID, + url: resolveGeoJsonUrl(), + name: 'Dynamics Field Service', + color: '#742774', + icon: 'DV', + source: 'Dataverse FS', + labels: true, + labelMax: 800, + labelGridPx: 140, +}); + +export function getDataverseFsLayer() { + return dataverseFsLayer; +} + +export async function setDataverseFsEnabled(viewer, enabled) { + if (!viewer) return; + if (enabled) await dataverseFsLayer.enable(viewer); + else await dataverseFsLayer.disable(viewer); +} + +export async function refreshDataverseFs(viewer) { + if (!viewer) return; + await dataverseFsLayer.disable(viewer); + await dataverseFsLayer.enable(viewer); +} + +export default dataverseFsLayer; \ No newline at end of file diff --git a/src/data/dataverseFs.test.mjs b/src/data/dataverseFs.test.mjs new file mode 100644 index 0000000..864850e --- /dev/null +++ b/src/data/dataverseFs.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeDataverseFsCollection } from './dataverseFs.js'; + +test('normalize drops null coords and empty input', () => { + assert.deepEqual(normalizeDataverseFsCollection(null).features, []); + assert.deepEqual(normalizeDataverseFsCollection({ type: 'FeatureCollection', features: [] }).features, []); + const bad = { + type: 'FeatureCollection', + features: [ + { type: 'Feature', geometry: { type: 'Point', coordinates: [null, 1] }, properties: { kind: 'site', id: '1', name: 'x' } }, + { type: 'Feature', geometry: { type: 'Point', coordinates: [-85.67, 42.96] }, properties: { kind: 'site', id: '2', name: 'ok' } }, + ], + }; + const out = normalizeDataverseFsCollection(bad); + assert.equal(out.features.length, 1); + assert.equal(out.features[0].properties.id, '2'); + assert.equal(out.features[0].properties.name, 'ok'); + assert.equal(out.features[0].properties.kind, 'site'); +}); + +test('normalize skips unknown kinds', () => { + const fc = { + type: 'FeatureCollection', + features: [ + { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 2] }, properties: { kind: 'spaceship', id: '9', name: 'nope' } }, + { type: 'Feature', geometry: { type: 'Point', coordinates: [3, 4] }, properties: { kind: 'workorder', id: '8', name: 'WO' } }, + ], + }; + const out = normalizeDataverseFsCollection(fc); + assert.equal(out.features.length, 1); + assert.equal(out.features[0].properties.kind, 'workorder'); +}); \ No newline at end of file diff --git a/src/data/localLayers.js b/src/data/localLayers.js index 3128c0d..a3cddd3 100644 --- a/src/data/localLayers.js +++ b/src/data/localLayers.js @@ -1,4 +1,5 @@ import { createLocalGeoJsonLayer } from './localGeojson.js'; +import dataverseFsLayer from './dataverseFs.js'; import { createFirmsHeatmapLayer } from './firmsHeatmap.js'; import submarineCablesLayer from './telegeographySubmarineCables.js'; @@ -45,6 +46,7 @@ const fires = createFirmsHeatmapLayer({ }); export default [ + dataverseFsLayer, datacenters, dams, submarineCablesLayer,