mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-07 12:57:57 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -35,6 +35,11 @@ import type {
|
||||
SigintSignal,
|
||||
Train,
|
||||
CorrelationAlert,
|
||||
UAPSighting,
|
||||
WastewaterPlant,
|
||||
CrowdThreatItem,
|
||||
SarAnomaly,
|
||||
SarAoi,
|
||||
} from '@/types/dashboard';
|
||||
import { classifyAircraft } from '@/utils/aircraftClassification';
|
||||
import { MISSION_COLORS, MISSION_ICON_MAP } from '@/components/map/icons/SatelliteIcons';
|
||||
@@ -217,16 +222,35 @@ export function buildCorrelationsGeoJSON(alerts?: CorrelationAlert[]): FC {
|
||||
rf_anomaly: { high: 0.40, medium: 0.25, low: 0.15 },
|
||||
military_buildup: { high: 0.40, medium: 0.25, low: 0.15 },
|
||||
infra_cascade: { high: 0.45, medium: 0.30, low: 0.20 },
|
||||
contradiction: { high: 0.35, medium: 0.25, low: 0.15 },
|
||||
analysis_zone: { high: 0.35, medium: 0.22, low: 0.12 },
|
||||
};
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
id: a.id || `corr-${i}`,
|
||||
type: 'correlation',
|
||||
corr_type: a.type,
|
||||
severity: a.severity,
|
||||
score: a.score,
|
||||
drivers: (a.drivers || []).join(' + '),
|
||||
opacity: opacityMap[a.type]?.[a.severity] ?? 0.2,
|
||||
corr_index: i,
|
||||
// Contradiction extras
|
||||
...(a.type === 'contradiction' && {
|
||||
context: a.context || '',
|
||||
alternatives: (a.alternatives || []).join(' | '),
|
||||
location_name: a.location_name || '',
|
||||
}),
|
||||
// Analysis zone extras (OpenClaw-placed)
|
||||
...(a.type === 'analysis_zone' && {
|
||||
zone_id: a.id || '',
|
||||
zone_title: a.title || '',
|
||||
zone_body: a.body || '',
|
||||
zone_category: a.category || 'analysis',
|
||||
zone_source: a.source || 'openclaw',
|
||||
zone_deletable: true,
|
||||
}),
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
@@ -906,6 +930,33 @@ export function buildShipsGeoJSON(
|
||||
|
||||
// ─── Carriers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeShipName(value: string | undefined | null): string {
|
||||
return (value || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function getShipIconId(ship: Pick<Ship, 'type' | 'yacht_alert'> | null | undefined): string {
|
||||
if (!ship) return 'svgShipBlue';
|
||||
const isTrackedYacht = !!ship.yacht_alert;
|
||||
const isMilitary = ship.type === 'carrier' || ship.type === 'military_vessel';
|
||||
const isCargo = ship.type === 'tanker' || ship.type === 'cargo';
|
||||
const isPassenger = ship.type === 'passenger';
|
||||
|
||||
if (isTrackedYacht) return 'svgShipPink';
|
||||
if (isCargo) return 'svgShipRed';
|
||||
if (ship.type === 'yacht' || isPassenger) return 'svgShipWhite';
|
||||
if (isMilitary) return 'svgShipAmber';
|
||||
return 'svgShipBlue';
|
||||
}
|
||||
|
||||
function getShipCategory(ship: Pick<Ship, 'type' | 'yacht_alert'> | null | undefined): string {
|
||||
if (!ship) return 'civilian';
|
||||
if (ship.yacht_alert || ship.type === 'yacht') return 'yacht';
|
||||
if (ship.type === 'tanker' || ship.type === 'cargo') return 'cargo';
|
||||
if (ship.type === 'passenger') return 'passenger';
|
||||
if (ship.type === 'carrier' || ship.type === 'military_vessel') return 'military';
|
||||
return 'civilian';
|
||||
}
|
||||
|
||||
// ─── SIGINT GeoJSON ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildSigintFeature(sig: SigintSignal): GeoJSON.Feature | null {
|
||||
@@ -1196,24 +1247,38 @@ export function buildVolcanoesGeoJSON(volcanoes?: Volcano[]): FC {
|
||||
|
||||
// ─── Fishing Activity ───────────────────────────────────────────────────────
|
||||
|
||||
export function buildFishingActivityGeoJSON(events?: FishingEvent[]): FC {
|
||||
export function buildFishingActivityGeoJSON(events?: FishingEvent[], ships?: Ship[]): FC {
|
||||
if (!events?.length) return null;
|
||||
const shipsByName = new Map<string, Ship>();
|
||||
for (const ship of ships || []) {
|
||||
const normalizedName = normalizeShipName(ship.name);
|
||||
if (normalizedName && !shipsByName.has(normalizedName)) {
|
||||
shipsByName.set(normalizedName, ship);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: events.map((e, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: e.id || `fish-${i}`,
|
||||
type: 'fishing_event',
|
||||
vessel_name: e.vessel_name,
|
||||
vessel_flag: e.vessel_flag,
|
||||
event_type: e.type,
|
||||
start: e.start,
|
||||
end: e.end,
|
||||
duration_hrs: e.duration_hrs,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] },
|
||||
})),
|
||||
features: events.map((e, i) => {
|
||||
const matchedShip = shipsByName.get(normalizeShipName(e.vessel_name));
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: e.id || `fish-${i}`,
|
||||
type: 'fishing_event',
|
||||
vessel_name: e.vessel_name,
|
||||
vessel_flag: e.vessel_flag,
|
||||
event_type: e.type,
|
||||
start: e.start,
|
||||
end: e.end,
|
||||
duration_hrs: e.duration_hrs,
|
||||
iconId: getShipIconId(matchedShip),
|
||||
shipCategory: getShipCategory(matchedShip),
|
||||
aisMatched: !!matchedShip,
|
||||
rotation: matchedShip?.heading || 0,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1307,3 +1372,406 @@ export function buildISSFootprintGeoJSON(
|
||||
features: [geoCircle(lng, lat, footprintKm)],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── AI Intel Layer ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIIntelPinData {
|
||||
id: string;
|
||||
layer_id?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
category: string;
|
||||
color: string;
|
||||
description: string;
|
||||
source: string;
|
||||
source_url: string;
|
||||
confidence: number;
|
||||
created_at: string;
|
||||
entity_attachment?: {
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
entity_label?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Resolve the live position of an entity-attached pin from telemetry data. */
|
||||
function resolveEntityPosition(
|
||||
attachment: NonNullable<AIIntelPinData['entity_attachment']>,
|
||||
data?: DashboardData | null,
|
||||
): { lat: number; lng: number } | null {
|
||||
if (!data) return null;
|
||||
const id = attachment.entity_id;
|
||||
const t = attachment.entity_type;
|
||||
|
||||
// Flight types — keyed by icao24
|
||||
if (t === 'flight' || t === 'commercial_flight') {
|
||||
const e = data.commercial_flights?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'private_flight' || t === 'private_ga') {
|
||||
const e = data.private_flights?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'private_jet') {
|
||||
const e = data.private_jets?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'military_flight') {
|
||||
const e = data.military_flights?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'tracked_flight') {
|
||||
const e = data.tracked_flights?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'uav') {
|
||||
const e = data.uavs?.find((u) => String(u.id) === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
|
||||
// Ships — keyed by MMSI
|
||||
if (t === 'ship') {
|
||||
const e = data.ships?.find((s) => String(s.mmsi) === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
|
||||
// Satellites — keyed by numeric ID
|
||||
if (t === 'satellite') {
|
||||
const e = data.satellites?.find((s) => String(s.id) === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
|
||||
// Trains — keyed by id
|
||||
if (t === 'train') {
|
||||
const e = data.trains?.find((tr) => tr.id === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
|
||||
// Fallback: search all flight arrays if generic "flight" didn't match
|
||||
if (t === 'flight') {
|
||||
for (const arr of [data.private_flights, data.private_jets, data.military_flights, data.tracked_flights, data.uavs] as Array<Array<{ icao24?: string; id?: string | number; lat: number; lng: number }> | undefined>) {
|
||||
const e = arr?.find((f) => (f.icao24 ?? String(f.id)) === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAIIntelGeoJSON(pins?: AIIntelPinData[], data?: DashboardData | null): FC {
|
||||
if (!pins?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: pins
|
||||
.filter((pin) => pin.lat != null && pin.lng != null)
|
||||
.map((pin) => {
|
||||
// For entity-attached pins, resolve live position from telemetry
|
||||
let lat = pin.lat;
|
||||
let lng = pin.lng;
|
||||
let tracking = false;
|
||||
if (pin.entity_attachment?.entity_type && pin.entity_attachment?.entity_id) {
|
||||
const live = resolveEntityPosition(pin.entity_attachment, data);
|
||||
if (live) {
|
||||
lat = live.lat;
|
||||
lng = live.lng;
|
||||
tracking = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
type: 'ai_intel_pin',
|
||||
id: pin.id,
|
||||
layer_id: pin.layer_id || '',
|
||||
name: pin.label,
|
||||
label: pin.label,
|
||||
category: pin.category,
|
||||
color: pin.color || '#3b82f6',
|
||||
description: pin.description,
|
||||
source: pin.source,
|
||||
source_url: pin.source_url,
|
||||
confidence: pin.confidence,
|
||||
created_at: pin.created_at,
|
||||
entity_type: pin.entity_attachment?.entity_type || '',
|
||||
entity_id: pin.entity_attachment?.entity_id || '',
|
||||
tracking,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [lng, lat],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── UAP Sightings ─────────────────────────────────────────────────────────
|
||||
|
||||
const UAP_SHAPE_COLORS: Record<string, string> = {
|
||||
triangle: '#ef4444', // Red
|
||||
orb: '#3b82f6', // Blue
|
||||
light: '#facc15', // Yellow
|
||||
disk: '#a855f7', // Purple
|
||||
cigar: '#f97316', // Orange
|
||||
'tic-tac': '#22d3ee', // Cyan
|
||||
fireball: '#dc2626', // Deep red
|
||||
formation: '#10b981', // Emerald
|
||||
diamond: '#e879f9', // Fuchsia
|
||||
rectangle: '#6366f1', // Indigo
|
||||
flash: '#fbbf24', // Amber
|
||||
changing: '#8b5cf6', // Violet
|
||||
unknown: '#9ca3af', // Grey
|
||||
};
|
||||
|
||||
// ─── CrowdThreat ──────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCrowdThreatGeoJSON(threats?: CrowdThreatItem[], inView?: InViewFilter): FC {
|
||||
if (!threats?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: threats
|
||||
.map((t) => {
|
||||
if (t.lat == null || t.lng == null) return null;
|
||||
if (inView && !inView(t.lat, t.lng)) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: `ct-${t.id}`,
|
||||
type: 'crowdthreat',
|
||||
title: t.title,
|
||||
summary: t.summary || '',
|
||||
category: t.category,
|
||||
category_colour: t.category_colour,
|
||||
subcategory: t.subcategory,
|
||||
threat_type: t.threat_type,
|
||||
address: t.address,
|
||||
city: t.city,
|
||||
country: t.country || '',
|
||||
timeago: t.timeago,
|
||||
occurred: t.occurred,
|
||||
occurred_iso: t.occurred_iso || '',
|
||||
verification: t.verification || '',
|
||||
severity: t.severity || '',
|
||||
source_url: t.source_url || '',
|
||||
votes: t.votes || 0,
|
||||
reporter: t.reporter || '',
|
||||
iconId: t.icon_id,
|
||||
name: t.title,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lng, t.lat] },
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GeoJSON.Feature[],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Wastewater colors by alert level ────────────────────────────────────
|
||||
const WW_COLORS = {
|
||||
alert: '#ff3333', // red — elevated pathogen detected
|
||||
active: '#00e5ff', // cyan — recent data, no alert
|
||||
stale: '#556677', // gray — plant exists but no recent data
|
||||
};
|
||||
|
||||
export function buildWastewaterGeoJSON(plants?: WastewaterPlant[]): FC {
|
||||
if (!plants?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: plants
|
||||
.filter((p) => p.lat != null && p.lng != null)
|
||||
.map((p, i) => {
|
||||
const hasAlerts = p.alert_count > 0;
|
||||
const hasData = p.pathogens && p.pathogens.length > 0;
|
||||
const color = hasAlerts ? WW_COLORS.alert : hasData ? WW_COLORS.active : WW_COLORS.stale;
|
||||
const icon = hasAlerts ? 'ww-alert' : hasData ? 'ww-clean' : 'ww-stale';
|
||||
const alertPathogens = (p.pathogens || []).filter((pt) => pt.alert).map((pt) => pt.name);
|
||||
const allPathogens = (p.pathogens || []).map((pt) => pt.name);
|
||||
// Build a rich label: name + location + alert info
|
||||
const loc = [p.city, p.state].filter(Boolean).join(', ');
|
||||
const labelParts = [p.name || p.site_name || 'Treatment Plant'];
|
||||
if (loc) labelParts.push(loc);
|
||||
if (hasAlerts && alertPathogens.length > 0) {
|
||||
labelParts.push(`⚠ ${alertPathogens.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: p.id || `ww-${i}`,
|
||||
type: 'wastewater',
|
||||
name: p.name || p.site_name || 'Treatment Plant',
|
||||
label: labelParts.join('\n'),
|
||||
site_name: p.site_name,
|
||||
city: p.city,
|
||||
state: p.state,
|
||||
population: p.population,
|
||||
collection_date: p.collection_date,
|
||||
pathogen_count: (p.pathogens || []).length,
|
||||
alert_count: p.alert_count,
|
||||
alert_pathogens: alertPathogens.join(', '),
|
||||
detected_pathogens: allPathogens.join(', '),
|
||||
// Serialize pathogen details for fallback popup rendering
|
||||
pathogens_json: JSON.stringify(p.pathogens || []),
|
||||
color,
|
||||
icon,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [p.lng, p.lat],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUapSightingsGeoJSON(sightings?: UAPSighting[]): FC {
|
||||
if (!sightings?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: sightings
|
||||
.filter((s) => s.lat != null && s.lng != null)
|
||||
.map((s, i) => {
|
||||
// Build a rich label with all available info
|
||||
const location = [s.city, s.state].filter(Boolean).join(', ') || 'Unknown location';
|
||||
const dateStr = s.date_time || 'Date unknown';
|
||||
// Format: "City, ST — Date" for the map label
|
||||
const label = `${location}\n${dateStr}`;
|
||||
// Popup-friendly name with count if available
|
||||
const countMatch = s.summary?.match(/(\d+)\s*sighting/);
|
||||
const count = countMatch ? parseInt(countMatch[1], 10) : 1;
|
||||
const name = count > 1
|
||||
? `${count} sightings — ${location}`
|
||||
: `UAP Sighting — ${location}`;
|
||||
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: s.id || `uap-${i}`,
|
||||
type: 'uap_sighting',
|
||||
shape: s.shape || 'unknown',
|
||||
shape_raw: s.shape_raw || s.shape || 'Unknown',
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
country: s.country,
|
||||
date_time: s.date_time,
|
||||
duration: s.duration,
|
||||
summary: s.summary,
|
||||
source: s.source || 'NUFORC',
|
||||
count,
|
||||
color: UAP_SHAPE_COLORS[s.shape] || UAP_SHAPE_COLORS.unknown,
|
||||
name,
|
||||
label,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [s.lng, s.lat],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── SAR (Synthetic Aperture Radar) ────────────────────────────────────────
|
||||
|
||||
/** Colors keyed by SAR anomaly `kind`. Matches sar_normalize._kind_to_pin_category
|
||||
* so the map and pin store agree on semantics. */
|
||||
const SAR_KIND_COLORS: Record<string, string> = {
|
||||
ground_deformation: '#f97316', // orange — subsidence, landslides
|
||||
surface_water_change: '#06b6d4', // cyan — flood/water extent
|
||||
flood_extent: '#06b6d4',
|
||||
vegetation_disturbance: '#22c55e', // green — deforestation, burn, blast
|
||||
damage_assessment: '#ef4444', // red — UNOSAT / EMS damage polygons
|
||||
coherence_change: '#a855f7', // purple — generic scatter change
|
||||
};
|
||||
|
||||
const SAR_DEFAULT_COLOR = '#eab308';
|
||||
|
||||
export function buildSarAnomaliesGeoJSON(anomalies?: SarAnomaly[]): FC {
|
||||
if (!anomalies?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: anomalies
|
||||
.filter((a) => Number.isFinite(a.lat) && Number.isFinite(a.lon))
|
||||
.map((a) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: a.anomaly_id,
|
||||
type: 'sar_anomaly',
|
||||
kind: a.kind,
|
||||
name: a.title || `SAR ${a.kind}`,
|
||||
title: a.title || '',
|
||||
summary: a.summary || '',
|
||||
solver: a.solver || '',
|
||||
source_constellation: a.source_constellation || '',
|
||||
magnitude: a.magnitude ?? 0,
|
||||
magnitude_unit: a.magnitude_unit || '',
|
||||
confidence: a.confidence ?? 0,
|
||||
first_seen: a.first_seen ?? 0,
|
||||
last_seen: a.last_seen ?? 0,
|
||||
aoi_id: a.aoi_id || '',
|
||||
scene_count: a.scene_count ?? 0,
|
||||
category: a.category || 'watchlist',
|
||||
provenance_url: a.provenance_url || '',
|
||||
evidence_hash: a.evidence_hash || '',
|
||||
color: SAR_KIND_COLORS[a.kind] || SAR_DEFAULT_COLOR,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [a.lon, a.lat],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Draw AOIs as filled circles (approximated with a 64-vertex polygon). These
|
||||
* mark the operator's watchboxes — visible even before any anomalies arrive. */
|
||||
export function buildSarAoisGeoJSON(aois?: SarAoi[]): FC {
|
||||
if (!aois?.length) return null;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (const aoi of aois) {
|
||||
if (!Array.isArray(aoi.center) || aoi.center.length < 2) continue;
|
||||
const [lat, lon] = aoi.center;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
|
||||
// Use explicit polygon if provided, else build a 64-point circle.
|
||||
let ring: number[][];
|
||||
if (Array.isArray(aoi.polygon) && aoi.polygon.length >= 3) {
|
||||
ring = aoi.polygon.map((pt) => [pt[1], pt[0]]); // [lat,lon] → [lon,lat]
|
||||
// Ensure ring is closed
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([...first]);
|
||||
} else {
|
||||
const radiusKm = Math.max(1, aoi.radius_km || 25);
|
||||
const steps = 64;
|
||||
ring = [];
|
||||
const kmPerDegLat = 111.32;
|
||||
const kmPerDegLon = 111.32 * Math.cos((lat * Math.PI) / 180);
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const theta = (i / steps) * 2 * Math.PI;
|
||||
const dLat = (radiusKm * Math.sin(theta)) / kmPerDegLat;
|
||||
const dLon = (radiusKm * Math.cos(theta)) / Math.max(0.0001, kmPerDegLon);
|
||||
ring.push([lon + dLon, lat + dLat]);
|
||||
}
|
||||
}
|
||||
|
||||
features.push({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: aoi.id,
|
||||
type: 'sar_aoi',
|
||||
name: aoi.name || aoi.id,
|
||||
description: aoi.description || '',
|
||||
category: aoi.category || 'watchlist',
|
||||
radius_km: aoi.radius_km || 0,
|
||||
center_lat: lat,
|
||||
center_lon: lon,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [ring],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (features.length === 0) return null;
|
||||
return { type: 'FeatureCollection' as const, features };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user