Files
goodgo-platform/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx
Ho Ngoc Hai c15bdcc6bf
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
fix(industrial): improve OSM review UX + public map visibility
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>
2026-04-30 00:09:24 +07:00

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>
);
}