Some checks failed
CI / E2E Tests (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 9s
CI / AI Services (Python) — Smoke (push) Failing after 7s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m7s
Deploy / Build API Image (push) Failing after 16s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 15s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m13s
Security Scanning / Trivy Scan — Web Image (push) Failing after 49s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 40s
Security Scanning / Trivy Filesystem Scan (push) Failing after 40s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Four UX issues surfaced when reviewing the new OSM-sync pipeline against the actual 2,193 imports — fixed in this commit: 1. Admin queue surfaced noise first. `ListOsmPendingHandler` now sorts by `totalAreaHa DESC` (real KCN first, single-factory `landuse=industrial` polygons last) and accepts `minAreaHa` (default 50 ha) plus a `region` filter. The admin page exposes both as dropdowns — "Tất cả / ≥ 5 / ≥ 50 / ≥ 200 / ≥ 500 ha". Top-of-queue is now Bàu Bàng (2,597 ha) and Nhơn Trạch (2,535 ha). 2. Promote dialog said "KCN KCN Đại An" — duplicate prefix. Reworded to "Sắp promote: <name>" so the row name stands on its own. 3. Province was "Chưa xác định" on 2,107 of 2,193 OSM rows. The OSM tags lacked any addr:* hint, so the importer never had anything to write. Added `scripts/data/vn-province-centroids.ts` (63 provinces with capital-city coords) and a `nearestProvince(lat, lng)` fallback in `parseFeature()`. Shipped a one-shot backfill script `scripts/backfill-osm-provinces.ts` and ran it — every existing OSM row now has a province (Hồ Chí Minh: 408, Lạng Sơn: 232, Quảng Ninh: 220, Hà Nội: 172, Hải Phòng: 105, …). Admin can correct on promote if the nearest-centroid heuristic picked the wrong neighbour for a long-thin province. 4. Public map looked empty — only 20 curated parks visible. Added an opt-in toggle "Hiển thị KCN OSM" with a small legend above the map. When on, the bbox endpoint returns OSM raw rows too; markers render in amber (vs. green for curated) at slightly smaller radius and lower opacity, so the visual hierarchy stays clear. Refetch is wired through a ref so the toggle takes effect without remounting the map. Verified in browser preview: zoom-out shows clusters of 320 / 71 / etc. across the country with the toggle on, and just three small clusters (20 curated parks) when off. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
'use client';
|
|
|
|
/* eslint-disable import-x/no-named-as-default-member */
|
|
import mapboxgl from 'mapbox-gl';
|
|
import * as React from 'react';
|
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
import { useMapboxStyle } from '@/lib/mapbox-style';
|
|
|
|
const VN_CENTER: [number, number] = [106.0, 16.0];
|
|
const DEFAULT_ZOOM = 5;
|
|
|
|
const SOURCE_ID = 'osm-parks';
|
|
const CLUSTER_LAYER_ID = 'osm-parks-clusters';
|
|
const CLUSTER_COUNT_LAYER_ID = 'osm-parks-cluster-count';
|
|
const POINT_LAYER_ID = 'osm-parks-points';
|
|
const BOUNDARY_FILL_LAYER_ID = 'osm-parks-boundaries-fill';
|
|
const BOUNDARY_LINE_LAYER_ID = 'osm-parks-boundaries-line';
|
|
|
|
interface OsmParkBboxMapProps {
|
|
className?: string;
|
|
/** Override the bbox API path. Default = `${NEXT_PUBLIC_API_URL}/industrial/parks/by-bbox`. */
|
|
apiPath?: string;
|
|
/** Show raw OSM-imported parks (admin tools). Default false. */
|
|
includeOsmRaw?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Viewport-driven KCN map. Pulls parks from the bbox endpoint as the user
|
|
* pans/zooms — clusters at low zoom (<12), shows polygon outlines at
|
|
* high zoom. Designed for the public catalog where we have ~2000 OSM
|
|
* imports + 50 curated rows; loading the entire dataset eagerly would
|
|
* be wasteful.
|
|
*/
|
|
export function OsmParkBboxMap({
|
|
className,
|
|
apiPath,
|
|
includeOsmRaw = false,
|
|
}: OsmParkBboxMapProps) {
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
|
const fetchAbortRef = React.useRef<AbortController | null>(null);
|
|
const mapStyle = useMapboxStyle();
|
|
|
|
const apiBase = React.useMemo(() => {
|
|
if (apiPath) return apiPath;
|
|
const apiUrl = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3201/api/v1';
|
|
return `${apiUrl}/industrial/parks/by-bbox`;
|
|
}, [apiPath]);
|
|
|
|
// Capture the current includeOsmRaw value via a ref so the moveend
|
|
// handler always sees the latest without re-binding the listener.
|
|
const includeOsmRawRef = React.useRef(includeOsmRaw);
|
|
// Bumping this triggers a manual refetch when the toggle changes —
|
|
// the moveend handler alone doesn't fire on prop changes.
|
|
const refetchTokenRef = React.useRef<(() => void) | null>(null);
|
|
React.useEffect(() => {
|
|
includeOsmRawRef.current = includeOsmRaw;
|
|
refetchTokenRef.current?.();
|
|
}, [includeOsmRaw]);
|
|
|
|
React.useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
|
if (!token) return;
|
|
mapboxgl.accessToken = token;
|
|
|
|
const map = new mapboxgl.Map({
|
|
container: containerRef.current,
|
|
style: mapStyle,
|
|
center: VN_CENTER,
|
|
zoom: DEFAULT_ZOOM,
|
|
attributionControl: false,
|
|
});
|
|
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
|
|
map.addControl(
|
|
new mapboxgl.AttributionControl({ compact: true, customAttribution: 'Data © OSM' }),
|
|
'bottom-right',
|
|
);
|
|
mapRef.current = map;
|
|
|
|
const fetchParks = async () => {
|
|
try {
|
|
// Cancel any in-flight request — only the latest viewport matters.
|
|
fetchAbortRef.current?.abort();
|
|
const controller = new AbortController();
|
|
fetchAbortRef.current = controller;
|
|
const bounds = map.getBounds();
|
|
if (!bounds) return;
|
|
const sw = bounds.getSouthWest();
|
|
const ne = bounds.getNorthEast();
|
|
const zoom = Math.round(map.getZoom());
|
|
const params = new URLSearchParams({
|
|
south: sw.lat.toString(),
|
|
west: sw.lng.toString(),
|
|
north: ne.lat.toString(),
|
|
east: ne.lng.toString(),
|
|
zoom: zoom.toString(),
|
|
...(includeOsmRawRef.current ? { includeOsmRaw: 'true' } : {}),
|
|
});
|
|
const res = await fetch(`${apiBase}?${params}`, {
|
|
credentials: 'include',
|
|
signal: controller.signal,
|
|
});
|
|
if (!res.ok) return;
|
|
const fc = (await res.json()) as GeoJSON.FeatureCollection;
|
|
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
|
|
if (src) src.setData(fc);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === 'AbortError') return;
|
|
console.warn('[osm-park-bbox-map] fetch failed:', err);
|
|
}
|
|
};
|
|
|
|
map.on('load', () => {
|
|
// Empty source — populated by the first fetchParks() call below.
|
|
map.addSource(SOURCE_ID, {
|
|
type: 'geojson',
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
cluster: true,
|
|
clusterRadius: 50,
|
|
clusterMaxZoom: 11,
|
|
clusterProperties: {
|
|
// No extra metrics yet — total count is built-in.
|
|
},
|
|
});
|
|
|
|
// Cluster bubbles. Mapbox color parser only accepts literal colors,
|
|
// so we use hex constants matching our design-system primary token.
|
|
map.addLayer({
|
|
id: CLUSTER_LAYER_ID,
|
|
type: 'circle',
|
|
source: SOURCE_ID,
|
|
filter: ['has', 'point_count'],
|
|
paint: {
|
|
'circle-color': [
|
|
'step',
|
|
['get', 'point_count'],
|
|
'#22c55e', // primary
|
|
10,
|
|
'#f59e0b',
|
|
50,
|
|
'#ef4444',
|
|
],
|
|
'circle-radius': [
|
|
'step',
|
|
['get', 'point_count'],
|
|
16,
|
|
10,
|
|
22,
|
|
50,
|
|
30,
|
|
],
|
|
'circle-stroke-width': 2,
|
|
'circle-stroke-color': '#ffffff',
|
|
'circle-opacity': 0.9,
|
|
},
|
|
});
|
|
|
|
map.addLayer({
|
|
id: CLUSTER_COUNT_LAYER_ID,
|
|
type: 'symbol',
|
|
source: SOURCE_ID,
|
|
filter: ['has', 'point_count'],
|
|
layout: {
|
|
'text-field': ['get', 'point_count_abbreviated'],
|
|
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
|
|
'text-size': 12,
|
|
},
|
|
paint: { 'text-color': '#ffffff' },
|
|
});
|
|
|
|
// Individual park markers (centroid Points) when not clustered.
|
|
// Color is data-driven: green = curated (MANUAL / OSM_PROMOTED),
|
|
// amber = raw OSM imports awaiting admin review.
|
|
map.addLayer({
|
|
id: POINT_LAYER_ID,
|
|
type: 'circle',
|
|
source: SOURCE_ID,
|
|
filter: [
|
|
'all',
|
|
['!', ['has', 'point_count']],
|
|
['==', ['get', '_kind'], 'point'],
|
|
],
|
|
paint: {
|
|
'circle-color': [
|
|
'case',
|
|
['==', ['get', 'dataSource'], 'OSM'],
|
|
'#f59e0b', // amber for raw OSM
|
|
'#22c55e', // green for curated
|
|
],
|
|
'circle-radius': [
|
|
'case',
|
|
['==', ['get', 'dataSource'], 'OSM'],
|
|
5,
|
|
6,
|
|
],
|
|
'circle-stroke-color': '#ffffff',
|
|
'circle-stroke-width': 1.5,
|
|
'circle-opacity': [
|
|
'case',
|
|
['==', ['get', 'dataSource'], 'OSM'],
|
|
0.7,
|
|
1,
|
|
],
|
|
},
|
|
});
|
|
|
|
// Polygon outlines — only present when zoom >= 12 (server omits them
|
|
// at lower zoom). Fill layer for hit-test, line layer for stroke.
|
|
map.addLayer({
|
|
id: BOUNDARY_FILL_LAYER_ID,
|
|
type: 'fill',
|
|
source: SOURCE_ID,
|
|
filter: ['==', ['get', '_kind'], 'polygon'],
|
|
paint: {
|
|
'fill-color': [
|
|
'case',
|
|
['==', ['get', 'dataSource'], 'OSM'],
|
|
'#f59e0b',
|
|
'#22c55e',
|
|
],
|
|
'fill-opacity': [
|
|
'case',
|
|
['==', ['get', 'dataSource'], 'OSM'],
|
|
0.1,
|
|
0.18,
|
|
],
|
|
},
|
|
});
|
|
map.addLayer({
|
|
id: BOUNDARY_LINE_LAYER_ID,
|
|
type: 'line',
|
|
source: SOURCE_ID,
|
|
filter: ['==', ['get', '_kind'], 'polygon'],
|
|
paint: {
|
|
'line-color': [
|
|
'case',
|
|
['==', ['get', 'dataSource'], 'OSM'],
|
|
'#f59e0b',
|
|
'#22c55e',
|
|
],
|
|
'line-width': 2,
|
|
'line-opacity': [
|
|
'case',
|
|
['==', ['get', 'dataSource'], 'OSM'],
|
|
0.4,
|
|
0.6,
|
|
],
|
|
},
|
|
});
|
|
|
|
// Click handler on point/polygon → navigate to detail.
|
|
const onClick = (e: mapboxgl.MapLayerMouseEvent) => {
|
|
const f = e.features?.[0];
|
|
if (!f) return;
|
|
const slug = (f.properties as Record<string, unknown> | null)?.['slug'];
|
|
if (typeof slug === 'string' && slug.length > 0) {
|
|
window.location.href = `/vi/khu-cong-nghiep/${slug}`;
|
|
}
|
|
};
|
|
map.on('click', POINT_LAYER_ID, onClick);
|
|
map.on('click', BOUNDARY_FILL_LAYER_ID, onClick);
|
|
// Cursor feedback
|
|
for (const layerId of [POINT_LAYER_ID, BOUNDARY_FILL_LAYER_ID, CLUSTER_LAYER_ID]) {
|
|
map.on('mouseenter', layerId, () => {
|
|
map.getCanvas().style.cursor = 'pointer';
|
|
});
|
|
map.on('mouseleave', layerId, () => {
|
|
map.getCanvas().style.cursor = '';
|
|
});
|
|
}
|
|
// Cluster click — zoom in
|
|
map.on('click', CLUSTER_LAYER_ID, (e) => {
|
|
const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] });
|
|
const clusterFeature = features[0];
|
|
if (!clusterFeature) return;
|
|
const clusterId = clusterFeature.properties?.['cluster_id'];
|
|
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource;
|
|
if (typeof clusterId === 'number') {
|
|
src.getClusterExpansionZoom(
|
|
clusterId,
|
|
(err: Error | null | undefined, zoom: number | null | undefined) => {
|
|
if (err || zoom == null) return;
|
|
const geom = clusterFeature.geometry;
|
|
if (geom.type === 'Point') {
|
|
map.easeTo({ center: geom.coordinates as [number, number], zoom });
|
|
}
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
// Initial fetch + listen to viewport changes.
|
|
void fetchParks();
|
|
// Wire up the prop-change refetch (used when `includeOsmRaw` flips
|
|
// — the moveend listener alone doesn't fire on parent re-renders).
|
|
refetchTokenRef.current = () => {
|
|
void fetchParks();
|
|
};
|
|
});
|
|
|
|
map.on('moveend', () => {
|
|
void fetchParks();
|
|
});
|
|
|
|
return () => {
|
|
fetchAbortRef.current?.abort();
|
|
map.remove();
|
|
mapRef.current = null;
|
|
};
|
|
// We intentionally do NOT depend on includeOsmRaw — the ref-based
|
|
// approach avoids tearing down the map on every prop tick.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [apiBase]);
|
|
|
|
// Sync mapStyle (theme switch) without rebuilding the map.
|
|
React.useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
map.setStyle(mapStyle);
|
|
}, [mapStyle]);
|
|
|
|
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
|
|
|
return (
|
|
<div className={`relative overflow-hidden rounded-lg border ${className || 'h-[600px]'}`}>
|
|
<div ref={containerRef} className="h-full w-full" />
|
|
{!hasToken && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-muted text-sm text-muted-foreground">
|
|
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|