From 9f46cf3f4681cf799a74dfbba807fefab074548f Mon Sep 17 00:00:00 2001 From: Akeem King <13573860+KingAkeem@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:14:36 -0400 Subject: [PATCH] Add the native map workspace --- .github/workflows/node.js.yml | 3 + CHANGELOG.md | 22 ++++ README.md | 50 +++++--- package-lock.json | 58 ++++++--- package.json | 10 +- src/app.jsx | 231 +++++++++++++++++++++++++++++++++- src/index.css | 32 +++++ src/main.js | 15 ++- src/mapSearch.cjs | 37 ++++++ src/renderer.js | 2 +- tests/mapSearch.test.cjs | 38 ++++++ 11 files changed, 450 insertions(+), 48 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/mapSearch.cjs create mode 100644 tests/mapSearch.test.cjs diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2284b93..a128134 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -29,3 +29,6 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm test + - run: npm audit --omit=dev + - if: matrix.node-version == '22.x' + run: npm run package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5bb6384 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +## [Unreleased] + +### Added + +- Native Map workspace backed by the shared Social Mapping GeoJSON API. +- Place, radius, keyword, and source controls with interactive map and result/archive views. +- Provider degradation warnings, source and accuracy badges, provenance details, and associated identity/phone metadata. +- Social Mapping readiness in the Integrations workspace and platform startup checks. +- Unit coverage for canonical map query construction and GeoJSON normalization. + +### Changed + +- Core platform readiness now permits operation without X credentials; X availability remains a separate integration status. +- X credential updates recreate both profile search and Social Mapping services. +- Corrected package license metadata to GPL-3.0-only. + +### Accuracy + +- Phone records are displayed only as associations and never as phone/device location evidence. +- The UI distinguishes exact coordinates, place centroids, and unknown dataset accuracy. diff --git a/README.md b/README.md index 76676b5..5f6a940 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@ # Who Is It? -Who Is It? is the Electron investigation workspace for the OSINT Services Platform. +Who Is It? is the native Electron investigation workspace for the [OSINT Services Platform](https://github.com/osint-services/platform). -## Experience +## Workspaces -The application is organized into four work areas: +- **Search** combines public profile, phone, and imported identity results. Provider failures stay isolated and source filters can avoid unnecessary live API calls. +- **Map** calls the shared `/map/search` GeoJSON API directly. It provides place, radius, keyword, and source controls; an interactive Leaflet map; result and archive lists; provider warnings; source and accuracy badges; provenance; and associated profile/phone metadata. +- **Datasets** previews and maps CSV, JSON, JSONL, or NDJSON into sparse entity records, including optional explicit coordinate evidence. +- **Integrations** reports X/Tweepy, Twilio Lookup, local dataset, and Social Mapping readiness and supports protected credential replacement. +- **History** keeps recent profile and phone searches on the local device. -- **Search** accepts usernames and phone numbers in one workspace. Auto mode routes clearly formatted phone numbers to phone services and other values to profile services; explicit Profile and Phone modes handle ambiguous identifiers. The source filter can query all sources, only connected live APIs, or only imported datasets. Profile scans retain useful public evidence when paid X inspection has no credits, while phone results combine Twilio metadata with exact E.164 dataset matches. -- **Datasets** provides one entity ingestion pipeline for CSV, JSON, JSONL, or NDJSON. It previews rows, auto-maps familiar columns, and accepts sparse records containing a username, profile URL, phone number, or any combination of those identifiers. -- **Integrations** shows whether X/Tweepy, Twilio Lookup, and local datasets are configured and reachable, and provides credential replacement fields. -- **History** stores the last 50 searches locally and can rerun them. History never leaves the device. - -Provider failures are isolated by source. For example, a Twilio error appears alongside the current phone search while matching imported records remain usable. Selecting **Datasets only** prevents live-provider calls, which is useful when conserving API usage. +The Map workspace is native UI, not an embedded browser. The standalone [`social_mapping`](https://github.com/osint-services/social_mapping) client remains available for development and browser demos, and both clients consume the same API contract. ## Run and test @@ -25,32 +24,43 @@ npm start Useful commands: ```bash -npm test # CSV/JSON/JSONL parser tests -npm run package # unpacked desktop application -npm run make # distributable artifacts +npm test +npm run package +npm audit --omit=dev ``` -Development tools are closed by default. Set `WHOISIT_DEVTOOLS=1` before `npm start` when you want Electron DevTools to open automatically. +Set `WHOISIT_DEVTOOLS=1` before `npm start` to open Electron DevTools. + +## Map behavior and accuracy + +The map searches by investigator-entered place, radius, optional keyword, and one or both initial sources: -## Integration credentials +- **Datasets** returns only imported entities or profiles with a valid explicit latitude/longitude pair. +- **X** returns recent posts only when X provides exact coordinates or a place bounding box. Place results use the bounding-box centroid and are labeled `place` accuracy. -The Integrations workspace can save an X API bearer token, Twilio Account SID, and Twilio Auth Token to the parent platform's ignored `.env` file. Existing values are never returned to the renderer or displayed. Blank fields preserve their current values, writes are atomic, and the resulting file is restricted to the current operating-system user. +When X is unconfigured or unavailable, dataset results remain usable and the Map workspace displays a provider warning. Phone rows may appear as associated entity metadata but never create or imply a phone/device location. Free-text profile locations are not treated as post coordinates. -After saving, the application attempts to recreate only the affected Compose services. If the desktop process cannot access Docker, the credentials remain saved and the UI asks the user to restart the platform stack manually. +Nominatim resolves only the investigator's entered search origin, server-side. OpenStreetMap attribution appears on the map. A marker is evidence tied to a source record, not proof of a person's present location. ## Data imports -Files are parsed locally in the renderer and sent through nginx to `/datasets/import` as mapped entity rows. Imports are limited to 10 MB and 10,000 records. Every row requires at least one `Username`, `Profile URL`, or E.164 `Phone number`; all other mappings are optional. A row containing both profile and phone fields creates one entity with typed associated identifiers. +Files are parsed in the renderer and sent to `/datasets/import`. Imports are limited to 10 MB and 10,000 records. Every entity row requires a username, profile URL, or E.164 phone number. Optional coordinate fields are `latitude`, `longitude`, `location_accuracy`, and `location_source`; latitude and longitude must be a valid pair. -Imported results show associated identifiers alongside dataset provenance, source, observation time, confidence, normalized metadata, and the original row. Dataset files may contain sensitive or licensed data; users are responsible for access, retention, and permitted use. +Imported results retain dataset provenance, source, observation time, confidence, and the original row. Import only data you are authorized to retain and use. ## Service integration -The application talks to `http://127.0.0.1:80`: +The app calls `http://127.0.0.1:80`: - `/scan/{username}` - `/focus?url=...` - `/phone_search?phone_number=...` - `/datasets/...` +- `/map/search` +- `/map/readyz` + +Core platform readiness no longer requires an X credential. X integration status remains visible separately, while credential-free dataset search and mapping continue to work. + +## License -The main process checks all four services and can invoke the parent repository's `scripts/start.sh` when the stack is unavailable. +GPL-3.0-only. See [LICENSE](LICENSE). diff --git a/package-lock.json b/package-lock.json index b5f231a..2524e87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,16 @@ "": { "name": "whoisit", "version": "1.0.0", - "license": "MIT", + "license": "GPL-3.0-only", "dependencies": { "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.3.0", "@mui/material": "^6.3.0", "electron-squirrel-startup": "^1.0.1", + "leaflet": "^1.9.4", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0" }, "devDependencies": { "@babel/core": "^7.26.0", @@ -393,13 +395,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -1842,6 +1841,17 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -6911,6 +6921,12 @@ "shell-quote": "^1.8.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8615,6 +8631,20 @@ "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -8787,12 +8817,6 @@ "node": ">= 10.13.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -11034,9 +11058,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" diff --git a/package.json b/package.json index caab59b..7d6a340 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "whoisit", "productName": "whoisit", "version": "1.0.0", - "description": "A unified investigation workspace for public profile, phone, and imported dataset searches.", + "description": "A unified investigation workspace for public profiles, phones, imported datasets, and evidence-oriented maps.", "main": ".webpack/main", "scripts": { "start": "electron-forge start", @@ -31,18 +31,20 @@ "node-loader": "^2.1.0", "style-loader": "^3.3.4" }, - "keywords": [], + "keywords": ["osint", "investigation", "geospatial", "geojson", "leaflet"], "author": { "name": "Akeem King", "email": "akeemtlking@gmail.com" }, - "license": "MIT", + "license": "GPL-3.0-only", "dependencies": { "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.3.0", "@mui/material": "^6.3.0", "electron-squirrel-startup": "^1.0.1", + "leaflet": "^1.9.4", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0" } } diff --git a/src/app.jsx b/src/app.jsx index 26ecd12..b678ac2 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -36,6 +36,7 @@ import { ExpandMore, History, Key, + Map as MapIcon, PersonSearch, PhoneEnabled, Refresh, @@ -45,6 +46,8 @@ import { } from '@mui/icons-material'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; +import L from 'leaflet'; +import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet'; const { getColumns, @@ -56,11 +59,17 @@ const { resolveSearchType, sourceEnabled, } = require('./searchMode.cjs'); +const { + MAP_ARCHIVE_KEY, + buildMapSearchPath, + normalizeMapCollection, +} = require('./mapSearch.cjs'); const API_BASE = 'http://127.0.0.1:80'; const HISTORY_KEY = 'whoisit:search-history'; const tabs = [ { label: 'Search', icon: }, + { label: 'Map', icon: }, { label: 'Datasets', icon: }, { label: 'Integrations', icon: }, { label: 'History', icon: }, @@ -759,6 +768,221 @@ function IntegrationsWorkspace({ onPlatformRefresh }) { ); } +const mapEvidenceIcon = L.divIcon({ + className: 'whoisit-map-marker', + html: '', + iconSize: [22, 22], + iconAnchor: [11, 11], +}); +const mapOriginIcon = L.divIcon({ + className: 'whoisit-origin-marker', + html: '', + iconSize: [28, 28], + iconAnchor: [14, 14], +}); + +function loadMapArchive() { + try { + return JSON.parse(localStorage.getItem(MAP_ARCHIVE_KEY) || '[]'); + } catch { + return []; + } +} + +function ReframeMap({ origin, features }) { + const map = useMap(); + React.useEffect(() => { + const points = [origin, ...features] + .map((item) => item?.geometry?.coordinates) + .filter(Boolean) + .map(([longitude, latitude]) => [latitude, longitude]); + if (points.length === 1) map.setView(points[0], 10); + if (points.length > 1) map.fitBounds(points, { padding: [36, 36], maxZoom: 13 }); + }, [features, map, origin]); + return null; +} + +function EvidenceMap({ collection, onSelect }) { + const origin = collection.origin; + const initial = origin?.geometry?.coordinates || [-0.1278, 51.5074]; + return ( + + + + {origin?.geometry && ( + + Search origin
{origin.properties?.label}
+
+ )} + {collection.features.map((item) => ( + onSelect(item) }} + > + + {item.properties?.title || 'Mapped record'}
+ {item.properties?.source} · {item.properties?.location_accuracy || 'unknown accuracy'} +
+
+ ))} +
+ ); +} + +function MapRecordDetail({ feature, onArchive, archived }) { + if (!feature) return ; + const details = feature.properties || {}; + return ( + + + + {details.title || 'Mapped record'} + {details.text || 'No descriptive text was supplied.'} + + + + + + + {present(details.distance_miles) && } + {present(details.confidence) && } + + + + + + {details.associated_profiles?.length > 0 && ( + + Associated profiles + + {details.associated_profiles.map((profile) => ( + } label={`${profile.platform || 'Profile'} · @${profile.username}`} variant="outlined" /> + ))} + + + )} + {details.associated_phones?.length > 0 && ( + + Associated phone records + + {details.associated_phones.map((phone) => } label={phone.phone_number} variant="outlined" />)} + + Association metadata only. A phone record does not identify or imply a phone or device location. + + )} + + + ); +} + +function MapWorkspace() { + const [place, setPlace] = React.useState('London'); + const [radiusMiles, setRadiusMiles] = React.useState(25); + const [keyword, setKeyword] = React.useState(''); + const [sources, setSources] = React.useState('datasets,x'); + const [collection, setCollection] = React.useState({ type: 'FeatureCollection', features: [], providers: {} }); + const [selected, setSelected] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(''); + const [listMode, setListMode] = React.useState('results'); + const [archive, setArchive] = React.useState(loadMapArchive); + + const runMapSearch = async () => { + setLoading(true); + setError(''); + try { + const payload = normalizeMapCollection(await requestJson(buildMapSearchPath({ place, radiusMiles, keyword, sources }))); + setCollection(payload); + setSelected(payload.features[0] || null); + setListMode('results'); + } catch (reason) { + setError(reason.message); + } finally { + setLoading(false); + } + }; + + const archiveFeature = (feature) => { + const next = [feature, ...archive.filter((item) => item.id !== feature.id)].slice(0, 100); + setArchive(next); + localStorage.setItem(MAP_ARCHIVE_KEY, JSON.stringify(next)); + }; + const archivedIds = new Set(archive.map((item) => item.id)); + const visible = listMode === 'archive' ? archive : collection.features; + const warnings = Object.entries(collection.providers || {}).filter(([, provider]) => provider.warning); + + return ( + + + Map + Explore evidence-backed locations from imported datasets and provider-supplied X geospatial data. + + + + setPlace(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && runMapSearch()} /> + setRadiusMiles(event.target.value)} inputProps={{ min: 1, max: 1000 }} sx={{ minWidth: 150 }} /> + setKeyword(event.target.value)} /> + setSources(event.target.value)} sx={{ minWidth: 170 }}> + Datasets and X + Datasets only + X only + + + + {error && {error}} + {warnings.map(([name, provider]) => {humanize(name)}: {provider.warning})} + + {Object.entries(collection.providers || {}).map(([name, provider]) => ( + + ))} + Search-origin place resolution uses server-side Nominatim with OpenStreetMap attribution. + + + + + + + + + setListMode(value)} variant="fullWidth"> + + + + + {visible.map((feature) => ( + setSelected(feature)} + /> + ))} + {!visible.length && } + + + + + + + + + + A marker is an evidence record, not proof of a person's present location. Place-centroid results are less precise than exact coordinates. + + ); +} + function App() { const { status, refresh } = usePlatformStatus(); const [tab, setTab] = React.useState(0); @@ -1094,9 +1318,10 @@ function App() { : } /> )} - {tab === 1 && } - {tab === 2 && } - {tab === 3 && ( + {tab === 1 && } + {tab === 2 && } + {tab === 3 && } + {tab === 4 && ( Search historyRecent searches stay on this device and can be rerun with one click. diff --git a/src/index.css b/src/index.css index e0fa4cf..245f8a1 100644 --- a/src/index.css +++ b/src/index.css @@ -16,3 +16,35 @@ body { * { box-sizing: border-box; } + +.whoisit-map { + height: 100%; + width: 100%; + background: #102033; +} + +.whoisit-map-marker span, +.whoisit-origin-marker span { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; +} + +.whoisit-map-marker span { + border: 3px solid #fff; + background: #087c72; + box-shadow: 0 2px 8px #09111f; +} + +.whoisit-origin-marker span { + border: 5px solid #fff; + background: #ef984e; + box-shadow: 0 0 0 5px rgba(239, 152, 78, 0.35); +} + +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + color: #17212b; + background: #f5f8fb; +} diff --git a/src/main.js b/src/main.js index dc1566d..8b50f36 100644 --- a/src/main.js +++ b/src/main.js @@ -81,9 +81,9 @@ const probeEndpoint = (url) => { const checkPlatformAvailability = async () => { const probeUrls = [ 'http://127.0.0.1:80/scan/healthz', - 'http://127.0.0.1:80/focus/readyz', 'http://127.0.0.1:80/phone_search/healthz', 'http://127.0.0.1:80/datasets/healthz', + 'http://127.0.0.1:80/map/readyz', ]; for (const probeUrl of probeUrls) { @@ -116,10 +116,11 @@ const checkIntegrations = async () => { const configured = getConfiguredIntegrations( await readIntegrationConfiguration(), ); - const [x, twilio, datasets] = await Promise.all([ + const [x, twilio, datasets, mapping] = await Promise.all([ probeEndpoint('http://127.0.0.1:80/focus/readyz'), probeEndpoint('http://127.0.0.1:80/phone_search/healthz'), probeEndpoint('http://127.0.0.1:80/datasets/healthz'), + probeEndpoint('http://127.0.0.1:80/map/readyz'), ]); return { platformRootAvailable: existsSync(path.join(platformRoot, 'docker-compose.yml')), @@ -148,6 +149,14 @@ const checkIntegrations = async () => { reachable: datasets.ok, statusCode: datasets.status || null, }, + { + id: 'mapping', + name: 'Social Mapping', + client: 'GeoJSON / Leaflet', + configured: true, + reachable: mapping.ok, + statusCode: mapping.status || null, + }, ], }; }; @@ -296,7 +305,7 @@ ipcMain.handle('integrations:save', async (_event, credentials) => { try { const services = []; if (updated.updatedKeys.includes('TWEEPY_BEARER_TOKEN')) { - services.push('profile_search'); + services.push('profile_search', 'social_mapping'); } if ( updated.updatedKeys.includes('TWILIO_ACCOUNT_SID') diff --git a/src/mapSearch.cjs b/src/mapSearch.cjs new file mode 100644 index 0000000..50bd7c8 --- /dev/null +++ b/src/mapSearch.cjs @@ -0,0 +1,37 @@ +const MAP_ARCHIVE_KEY = 'whoisit:map-archive'; + +function buildMapSearchPath({ place, radiusMiles, keyword = '', sources = 'datasets,x', limit = 100 }) { + const normalizedPlace = String(place || '').trim(); + if (!normalizedPlace) throw new Error('Enter a place to search.'); + const radius = Number(radiusMiles); + if (!Number.isFinite(radius) || radius <= 0 || radius > 1000) { + throw new Error('Radius must be greater than 0 and no more than 1,000 miles.'); + } + const allowed = new Set(['datasets', 'x']); + const selected = String(sources).split(',').map((value) => value.trim()).filter(Boolean); + if (!selected.length || selected.some((source) => !allowed.has(source))) { + throw new Error('Select datasets, X, or both.'); + } + const parameters = new URLSearchParams({ + place: normalizedPlace, + radius_miles: String(radius), + sources: selected.join(','), + limit: String(limit), + }); + if (String(keyword).trim()) parameters.set('query', String(keyword).trim()); + return `/map/search?${parameters}`; +} + +function normalizeMapCollection(payload) { + if (!payload || payload.type !== 'FeatureCollection' || !Array.isArray(payload.features)) { + throw new Error('The map service returned an invalid GeoJSON response.'); + } + const features = payload.features.filter((item) => ( + item?.geometry?.type === 'Point' + && Array.isArray(item.geometry.coordinates) + && item.geometry.coordinates.length >= 2 + )); + return { ...payload, features }; +} + +module.exports = { MAP_ARCHIVE_KEY, buildMapSearchPath, normalizeMapCollection }; diff --git a/src/renderer.js b/src/renderer.js index 082a5b4..6312fbb 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -27,8 +27,8 @@ */ import './index.css'; +import 'leaflet/dist/leaflet.css'; import './app.jsx'; console.log('👋 This message is being logged by "renderer.js", included via webpack'); - diff --git a/tests/mapSearch.test.cjs b/tests/mapSearch.test.cjs new file mode 100644 index 0000000..a491c8a --- /dev/null +++ b/tests/mapSearch.test.cjs @@ -0,0 +1,38 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { buildMapSearchPath, normalizeMapCollection } = require('../src/mapSearch.cjs'); + +test('builds the canonical map query with source controls', () => { + const path = buildMapSearchPath({ + place: ' London ', + radiusMiles: 15, + keyword: 'museum', + sources: 'datasets,x', + }); + const url = new URL(path, 'http://localhost'); + assert.equal(url.pathname, '/map/search'); + assert.equal(url.searchParams.get('place'), 'London'); + assert.equal(url.searchParams.get('radius_miles'), '15'); + assert.equal(url.searchParams.get('query'), 'museum'); + assert.equal(url.searchParams.get('sources'), 'datasets,x'); +}); + +test('rejects invalid map input', () => { + assert.throws(() => buildMapSearchPath({ place: '', radiusMiles: 10, sources: 'datasets' }), /place/); + assert.throws(() => buildMapSearchPath({ place: 'London', radiusMiles: 0, sources: 'datasets' }), /Radius/); + assert.throws(() => buildMapSearchPath({ place: 'London', radiusMiles: 10, sources: 'phones' }), /Select datasets/); +}); + +test('keeps only valid GeoJSON point features', () => { + const payload = normalizeMapCollection({ + type: 'FeatureCollection', + features: [ + { id: 'valid', geometry: { type: 'Point', coordinates: [-0.1, 51.5] }, properties: {} }, + { id: 'invalid', geometry: null, properties: {} }, + ], + providers: {}, + }); + assert.deepEqual(payload.features.map((item) => item.id), ['valid']); + assert.throws(() => normalizeMapCollection({ features: [] }), /invalid GeoJSON/); +});