From c15bdcc6bff194ceb56ba996285a4465c2f60098 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 30 Apr 2026 00:09:24 +0700 Subject: [PATCH] fix(industrial): improve OSM review UX + public map visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: " 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) --- .../list-osm-pending.handler.ts | 13 ++- .../list-osm-pending.query.ts | 7 ++ .../industrial-parks.controller.ts | 4 + .../admin/industrial/osm-review/page.tsx | 67 ++++++++++- .../(public)/khu-cong-nghiep/page.tsx | 28 ++++- .../khu-cong-nghiep/osm-map-legend.tsx | 67 +++++++++++ .../khu-cong-nghiep/osm-park-bbox-map.tsx | 59 +++++++++- apps/web/lib/khu-cong-nghiep-api.ts | 3 + scripts/backfill-osm-provinces.ts | 94 +++++++++++++++ scripts/data/vn-province-centroids.ts | 107 ++++++++++++++++++ scripts/sync-osm-industrial-parks.ts | 11 +- 11 files changed, 442 insertions(+), 18 deletions(-) create mode 100644 apps/web/components/khu-cong-nghiep/osm-map-legend.tsx create mode 100644 scripts/backfill-osm-provinces.ts create mode 100644 scripts/data/vn-province-centroids.ts diff --git a/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts index d5445b3..ecdd25e 100644 --- a/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts +++ b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts @@ -50,6 +50,10 @@ export class ListOsmPendingHandler implements IQueryHandler conditions.push(`province = $${p++}`); values.push(q.province); } + if (q.region) { + conditions.push(`region::text = $${p++}`); + values.push(q.region); + } if (q.query) { conditions.push( `(name ILIKE $${p} OR "nameEn" ILIKE $${p} OR developer ILIKE $${p})`, @@ -57,6 +61,13 @@ export class ListOsmPendingHandler implements IQueryHandler values.push(`%${q.query}%`); p += 1; } + if (q.minAreaHa > 0) { + // Use COALESCE so rows whose area we couldn't compute (NODE-only + // imports) only show up when the admin explicitly drops the floor + // to 0. + conditions.push(`COALESCE("totalAreaHa", 0) >= $${p++}`); + values.push(q.minAreaHa); + } const where = conditions.join(' AND '); const [{ count }] = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>( @@ -95,7 +106,7 @@ export class ListOsmPendingHandler implements IQueryHandler ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng FROM "IndustrialPark" WHERE ${where} - ORDER BY "lastSyncedAt" DESC NULLS LAST, "totalAreaHa" DESC NULLS LAST + ORDER BY "totalAreaHa" DESC NULLS LAST, "lastSyncedAt" DESC NULLS LAST LIMIT $${p++} OFFSET $${p}`, ...values, limit, diff --git a/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts index 15226a8..07c69be 100644 --- a/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts +++ b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts @@ -1,6 +1,11 @@ /** * Admin OSM review queue — list raw OSM-imported parks that haven't yet * been promoted to the public catalogue. + * + * `minAreaHa` lets admins skip the long tail of `landuse=industrial` + * features OSM tags that turn out to be single factories or warehouses + * (typically < 5 ha). The default of 50 ha surfaces "real" KCN first; pass + * `0` to see everything. */ export class ListOsmPendingQuery { constructor( @@ -8,5 +13,7 @@ export class ListOsmPendingQuery { public readonly limit: number = 50, public readonly query?: string, public readonly province?: string, + public readonly minAreaHa: number = 50, + public readonly region?: string, ) {} } diff --git a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts index 0e4d167..f4e7a26 100644 --- a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts +++ b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts @@ -302,6 +302,8 @@ export class IndustrialParksController { @Query('limit') limit?: string, @Query('q') q?: string, @Query('province') province?: string, + @Query('minAreaHa') minAreaHa?: string, + @Query('region') region?: string, ) { return this.queryBus.execute( new ListOsmPendingQuery( @@ -309,6 +311,8 @@ export class IndustrialParksController { limit ? parseInt(limit, 10) : 50, q, province, + minAreaHa !== undefined ? Number(minAreaHa) : 50, + region, ), ); } diff --git a/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx b/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx index 37b6cde..1e2485f 100644 --- a/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx +++ b/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx @@ -35,8 +35,10 @@ import { import { Link } from '@/i18n/navigation'; import { industrialApi, + REGION_LABELS, type OsmPendingItem, type OsmPendingResult, + type VietnamRegion, } from '@/lib/khu-cong-nghiep-api'; /** @@ -56,6 +58,17 @@ import { const PAGE_SIZE = 50; +/** Buckets for the "Diện tích tối thiểu" filter. 50 ha is the default + * because most "real" KCN start there — anything below tends to be a + * single factory or warehouse mistagged as `landuse=industrial`. */ +const MIN_AREA_OPTIONS: { value: number; label: string }[] = [ + { value: 0, label: 'Tất cả' }, + { value: 5, label: '≥ 5 ha' }, + { value: 50, label: '≥ 50 ha (KCN nhỏ)' }, + { value: 200, label: '≥ 200 ha (KCN lớn)' }, + { value: 500, label: '≥ 500 ha (KCN trọng điểm)' }, +]; + const QUICK_LOCK_FIELDS: { key: string; label: string }[] = [ { key: 'name', label: 'Tên KCN' }, { key: 'developer', label: 'Chủ đầu tư' }, @@ -91,6 +104,8 @@ export default function AdminOsmReviewPage() { const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); const [provinceFilter, setProvinceFilter] = useState(''); + const [minAreaHa, setMinAreaHa] = useState(50); + const [region, setRegion] = useState(''); // Promote dialog state const [promoteTarget, setPromoteTarget] = useState(null); @@ -107,6 +122,8 @@ export default function AdminOsmReviewPage() { limit: PAGE_SIZE, q: search || undefined, province: provinceFilter || undefined, + minAreaHa, + region: region || undefined, }); setResult(data); } catch (e) { @@ -114,7 +131,7 @@ export default function AdminOsmReviewPage() { } finally { setLoading(false); } - }, [page, search, provinceFilter]); + }, [page, search, provinceFilter, minAreaHa, region]); useEffect(() => { fetchQueue(); @@ -218,6 +235,43 @@ export default function AdminOsmReviewPage() { className="h-8 text-sm" /> +
+ + +
+
+ + +
Tìm - {(search || provinceFilter) && ( + {(search || provinceFilter || region || minAreaHa !== 50) && ( )} @@ -444,8 +500,9 @@ export default function AdminOsmReviewPage() { - KCN {promoteTarget?.name} sẽ được chuyển sang trạng thái public - (OSM_PROMOTED). Chọn các trường muốn khóa để bảo vệ chúng khỏi OSM sync sau này. + Sắp promote: {promoteTarget?.name}. KCN sẽ chuyển sang trạng thái + public (OSM_PROMOTED). Chọn các trường muốn khóa để bảo vệ chúng khỏi OSM sync sau + này. diff --git a/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx b/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx index bf3e519..b103a94 100644 --- a/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx +++ b/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx @@ -2,6 +2,7 @@ import { Factory, Map as MapIcon, List, Columns } from 'lucide-react'; import * as React from 'react'; +import { OsmMapLegend } from '@/components/khu-cong-nghiep/osm-map-legend'; import { OsmParkBboxMap } from '@/components/khu-cong-nghiep/osm-park-bbox-map'; import { ParkCard } from '@/components/khu-cong-nghiep/park-card'; import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar'; @@ -19,6 +20,10 @@ export default function KhuCongNghiepPage() { limit: PAGE_SIZE, }); const [viewMode, setViewMode] = React.useState('split'); + // When true, the bbox map also shows raw OSM-imported parks (amber + // markers) on top of the curated catalog. Off by default — most users + // want only the verified set. + const [includeOsmRaw, setIncludeOsmRaw] = React.useState(false); const { data, isLoading, isError } = useIndustrialParksSearch(filters); @@ -113,7 +118,16 @@ export default function KhuCongNghiepPage() { {/* Map-only view — bbox-driven, loads ALL parks in viewport */} {viewMode === 'map' && ( - + <> + + + )} {/* Split view: list left, sticky bbox map right (lg+ only) */} @@ -126,8 +140,16 @@ export default function KhuCongNghiepPage() { ))}
-
- +
+ +
)} diff --git a/apps/web/components/khu-cong-nghiep/osm-map-legend.tsx b/apps/web/components/khu-cong-nghiep/osm-map-legend.tsx new file mode 100644 index 0000000..5539c9d --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/osm-map-legend.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Info } from 'lucide-react'; +import * as React from 'react'; + +interface OsmMapLegendProps { + includeOsmRaw: boolean; + onToggleOsmRaw: (value: boolean) => void; + /** Smaller variant for the split-view sidebar. */ + compact?: boolean; +} + +/** + * Legend + toggle that sits above the bbox map. Explains the two marker + * colors (curated vs raw OSM) and lets the user opt into showing the + * un-reviewed OpenStreetMap imports. + */ +export function OsmMapLegend({ + includeOsmRaw, + onToggleOsmRaw, + compact = false, +}: OsmMapLegendProps) { + return ( +
+
+
+
+
+ + + + {includeOsmRaw && !compact && ( +

+ + KCN màu vàng là dữ liệu thô từ OpenStreetMap, chưa được kiểm duyệt — thông tin có thể chưa + chính xác hoặc thiếu. +

+ )} +
+ ); +} diff --git a/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx b/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx index 6e7cbcb..e86c03e 100644 --- a/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx +++ b/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx @@ -50,8 +50,12 @@ export function OsmParkBboxMap({ // 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(() => { @@ -166,6 +170,8 @@ export function OsmParkBboxMap({ }); // 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', @@ -176,10 +182,26 @@ export function OsmParkBboxMap({ ['==', ['get', '_kind'], 'point'], ], paint: { - 'circle-color': '#22c55e', - 'circle-radius': 6, + '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, + ], }, }); @@ -191,8 +213,18 @@ export function OsmParkBboxMap({ source: SOURCE_ID, filter: ['==', ['get', '_kind'], 'polygon'], paint: { - 'fill-color': '#22c55e', - 'fill-opacity': 0.18, + 'fill-color': [ + 'case', + ['==', ['get', 'dataSource'], 'OSM'], + '#f59e0b', + '#22c55e', + ], + 'fill-opacity': [ + 'case', + ['==', ['get', 'dataSource'], 'OSM'], + 0.1, + 0.18, + ], }, }); map.addLayer({ @@ -201,9 +233,19 @@ export function OsmParkBboxMap({ source: SOURCE_ID, filter: ['==', ['get', '_kind'], 'polygon'], paint: { - 'line-color': '#22c55e', + 'line-color': [ + 'case', + ['==', ['get', 'dataSource'], 'OSM'], + '#f59e0b', + '#22c55e', + ], 'line-width': 2, - 'line-opacity': 0.6, + 'line-opacity': [ + 'case', + ['==', ['get', 'dataSource'], 'OSM'], + 0.4, + 0.6, + ], }, }); @@ -250,6 +292,11 @@ export function OsmParkBboxMap({ // 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', () => { diff --git a/apps/web/lib/khu-cong-nghiep-api.ts b/apps/web/lib/khu-cong-nghiep-api.ts index edfbc91..f5c5607 100644 --- a/apps/web/lib/khu-cong-nghiep-api.ts +++ b/apps/web/lib/khu-cong-nghiep-api.ts @@ -271,6 +271,9 @@ export interface ListOsmPendingParams { limit?: number; q?: string; province?: string; + /** Diện tích tối thiểu (ha). Default backend = 50 để lọc bớt nhà máy lẻ. */ + minAreaHa?: number; + region?: VietnamRegion; } // ─── Labels ───────────────────────────────────────────── diff --git a/scripts/backfill-osm-provinces.ts b/scripts/backfill-osm-provinces.ts new file mode 100644 index 0000000..e328902 --- /dev/null +++ b/scripts/backfill-osm-provinces.ts @@ -0,0 +1,94 @@ +/** + * One-shot backfill for OSM-imported `IndustrialPark` rows whose + * `province` is "Chưa xác định" (the placeholder we wrote when the OSM + * tags lacked any addr:* hints). + * + * Usage: + * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ + * pnpm tsx scripts/backfill-osm-provinces.ts [--dry-run] + * + * What it does: + * 1. Selects every row where dataSource = 'OSM' AND province = + * 'Chưa xác định'. + * 2. Reads the centroid via ST_X / ST_Y from the `location` Point. + * 3. Looks up the nearest province from VN_PROVINCE_CENTROIDS. + * 4. Updates the row in batches. + * + * Safe to re-run: skips rows where province is already filled in. + */ +import 'dotenv/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import pg from 'pg'; +import { nearestProvince } from './data/vn-province-centroids'; + +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const dryRun = process.argv.includes('--dry-run'); + +interface Row { + id: string; + lat: number; + lng: number; +} + +async function main(): Promise { + console.log('🔍 Finding OSM rows with province="Chưa xác định"…'); + + const rows = await prisma.$queryRawUnsafe( + `SELECT id, ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng + FROM "IndustrialPark" + WHERE "dataSource"::text = 'OSM' AND province = 'Chưa xác định'`, + ); + console.log(` → ${rows.length} rows need a province.`); + + if (!rows.length) { + console.log('✓ Nothing to do.'); + return; + } + + const updates = new Map(); + for (const row of rows) { + const province = nearestProvince(row.lat, row.lng); + if (!updates.has(province)) updates.set(province, []); + updates.get(province)!.push(row.id); + } + + // Sort by impact for the dry-run preview. + const summary = Array.from(updates.entries()).sort((a, b) => b[1].length - a[1].length); + console.log(' → Distribution by inferred province:'); + for (const [province, ids] of summary) { + console.log(` ${province.padEnd(24)} ${ids.length}`); + } + + if (dryRun) { + console.log('💡 --dry-run: no writes performed.'); + return; + } + + let totalUpdated = 0; + for (const [province, ids] of updates) { + // UPDATE in batches of 500 ids to avoid huge IN-lists. + for (let i = 0; i < ids.length; i += 500) { + const batch = ids.slice(i, i + 500); + const result = await prisma.industrialPark.updateMany({ + where: { id: { in: batch } }, + data: { province }, + }); + totalUpdated += result.count; + } + } + console.log(`✓ Updated ${totalUpdated} rows.`); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + await pool.end(); + }); diff --git a/scripts/data/vn-province-centroids.ts b/scripts/data/vn-province-centroids.ts new file mode 100644 index 0000000..83d50d4 --- /dev/null +++ b/scripts/data/vn-province-centroids.ts @@ -0,0 +1,107 @@ +/** + * Approximate centroids for Vietnam's 63 provinces / centrally-administered + * cities, used as a fallback when OSM `addr:state` / `addr:province` tags + * are missing (which is the common case for industrial-park polygons in VN). + * + * The coords are public-domain capital-city centroids (good enough for a + * "which province does this point land in?" nearest-neighbour lookup at + * the +/- few-km level — the long shape of some provinces means the + * nearest centroid will occasionally pick the wrong neighbour, but admin + * can correct on promote. + * + * Province name canonical form = display name in Vietnamese (no diacritics + * stripped) so it matches what the seed data + UI already uses. + */ + +export interface ProvinceCentroid { + name: string; + lat: number; + lng: number; +} + +export const VN_PROVINCE_CENTROIDS: ProvinceCentroid[] = [ + { name: 'Hà Nội', lat: 21.028511, lng: 105.804817 }, + { name: 'Hồ Chí Minh', lat: 10.762622, lng: 106.660172 }, + { name: 'Hải Phòng', lat: 20.844912, lng: 106.688084 }, + { name: 'Đà Nẵng', lat: 16.054407, lng: 108.202167 }, + { name: 'Cần Thơ', lat: 10.045162, lng: 105.746857 }, + { name: 'An Giang', lat: 10.521583, lng: 105.125896 }, + { name: 'Bà Rịa - Vũng Tàu', lat: 10.541136, lng: 107.242851 }, + { name: 'Bạc Liêu', lat: 9.294085, lng: 105.721787 }, + { name: 'Bắc Giang', lat: 21.281932, lng: 106.197658 }, + { name: 'Bắc Kạn', lat: 22.147291, lng: 105.834160 }, + { name: 'Bắc Ninh', lat: 21.186080, lng: 106.076371 }, + { name: 'Bến Tre', lat: 10.243519, lng: 106.375140 }, + { name: 'Bình Dương', lat: 11.325237, lng: 106.477017 }, + { name: 'Bình Định', lat: 13.782250, lng: 109.219574 }, + { name: 'Bình Phước', lat: 11.751888, lng: 106.723917 }, + { name: 'Bình Thuận', lat: 11.090668, lng: 108.072094 }, + { name: 'Cà Mau', lat: 9.176790, lng: 105.150253 }, + { name: 'Cao Bằng', lat: 22.665684, lng: 106.257549 }, + { name: 'Đắk Lắk', lat: 12.710017, lng: 108.237633 }, + { name: 'Đắk Nông', lat: 12.264144, lng: 107.609794 }, + { name: 'Điện Biên', lat: 21.386073, lng: 103.016510 }, + { name: 'Đồng Nai', lat: 11.066000, lng: 107.166700 }, + { name: 'Đồng Tháp', lat: 10.493333, lng: 105.688200 }, + { name: 'Gia Lai', lat: 13.808078, lng: 108.109375 }, + { name: 'Hà Giang', lat: 22.823453, lng: 104.978573 }, + { name: 'Hà Nam', lat: 20.541127, lng: 105.913303 }, + { name: 'Hà Tĩnh', lat: 18.342745, lng: 105.905499 }, + { name: 'Hải Dương', lat: 20.940981, lng: 106.330156 }, + { name: 'Hậu Giang', lat: 9.757897, lng: 105.641110 }, + { name: 'Hòa Bình', lat: 20.817192, lng: 105.337720 }, + { name: 'Hưng Yên', lat: 20.852241, lng: 106.015511 }, + { name: 'Khánh Hòa', lat: 12.258515, lng: 109.052528 }, + { name: 'Kiên Giang', lat: 10.012103, lng: 105.080921 }, + { name: 'Kon Tum', lat: 14.349953, lng: 108.000534 }, + { name: 'Lai Châu', lat: 22.396519, lng: 103.471370 }, + { name: 'Lâm Đồng', lat: 11.940419, lng: 108.458313 }, + { name: 'Lạng Sơn', lat: 21.853708, lng: 106.761525 }, + { name: 'Lào Cai', lat: 22.485198, lng: 103.974845 }, + { name: 'Long An', lat: 10.535359, lng: 106.405460 }, + { name: 'Nam Định', lat: 20.438822, lng: 106.162106 }, + { name: 'Nghệ An', lat: 18.679585, lng: 105.681753 }, + { name: 'Ninh Bình', lat: 20.250716, lng: 105.974623 }, + { name: 'Ninh Thuận', lat: 11.673911, lng: 108.864225 }, + { name: 'Phú Thọ', lat: 21.398716, lng: 105.151787 }, + { name: 'Phú Yên', lat: 13.088067, lng: 109.092876 }, + { name: 'Quảng Bình', lat: 17.478366, lng: 106.621288 }, + { name: 'Quảng Nam', lat: 15.539353, lng: 108.019047 }, + { name: 'Quảng Ngãi', lat: 15.120137, lng: 108.804340 }, + { name: 'Quảng Ninh', lat: 21.006382, lng: 107.292514 }, + { name: 'Quảng Trị', lat: 16.745360, lng: 107.187462 }, + { name: 'Sóc Trăng', lat: 9.602522, lng: 105.973797 }, + { name: 'Sơn La', lat: 21.328228, lng: 103.914400 }, + { name: 'Tây Ninh', lat: 11.310300, lng: 106.098046 }, + { name: 'Thái Bình', lat: 20.446016, lng: 106.336445 }, + { name: 'Thái Nguyên', lat: 21.594280, lng: 105.844866 }, + { name: 'Thanh Hóa', lat: 19.806692, lng: 105.785182 }, + { name: 'Thừa Thiên Huế', lat: 16.467397, lng: 107.590533 }, + { name: 'Tiền Giang', lat: 10.449334, lng: 106.342120 }, + { name: 'Trà Vinh', lat: 9.812741, lng: 106.299250 }, + { name: 'Tuyên Quang', lat: 21.778812, lng: 105.218015 }, + { name: 'Vĩnh Long', lat: 10.253938, lng: 105.972456 }, + { name: 'Vĩnh Phúc', lat: 21.308690, lng: 105.604813 }, + { name: 'Yên Bái', lat: 21.722733, lng: 104.911289 }, +]; + +/** + * Find the nearest province centroid for a given lat/lng. Uses naive squared + * distance — fine for VN's ~1500-km north-south extent. Always returns a + * province (the dataset covers the country); callers can fall back to + * "Chưa xác định" if they need to. + */ +export function nearestProvince(lat: number, lng: number): string { + let best = VN_PROVINCE_CENTROIDS[0]; + let bestD = Infinity; + for (const p of VN_PROVINCE_CENTROIDS) { + const dLat = lat - p.lat; + const dLng = lng - p.lng; + const d = dLat * dLat + dLng * dLng; + if (d < bestD) { + bestD = d; + best = p; + } + } + return best.name; +} diff --git a/scripts/sync-osm-industrial-parks.ts b/scripts/sync-osm-industrial-parks.ts index 9dc15bc..a4328ce 100644 --- a/scripts/sync-osm-industrial-parks.ts +++ b/scripts/sync-osm-industrial-parks.ts @@ -25,13 +25,14 @@ */ import 'dotenv/config'; import { createId } from '@paralleldrive/cuid2'; -import area from '@turf/area'; -import centroid from '@turf/centroid'; import { PrismaPg } from '@prisma/adapter-pg'; import { type Prisma, PrismaClient } from '@prisma/client'; +import area from '@turf/area'; +import centroid from '@turf/centroid'; import type { Feature, MultiPolygon, Polygon, Point } from 'geojson'; import osmtogeojson from 'osmtogeojson'; import pg from 'pg'; +import { nearestProvince } from './data/vn-province-centroids'; const generateCuid = (): Promise => Promise.resolve(createId()); @@ -200,10 +201,14 @@ function parseFeature( totalAreaHa = Math.round((area(feat as Feature) / 10000) * 100) / 100; } + // Province resolution: prefer explicit OSM tags, then fall back to a + // nearest-centroid lookup against our 63-province table. The fallback + // catches the (very common) case where Vietnamese landuse polygons have + // no addr:* tags at all. const province = VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ?? tags['addr:city'] ?? - 'Chưa xác định'; + nearestProvince(cLat, cLng); const district = tags['addr:district'] ?? tags['addr:suburb'] ?? ''; const address = tags['addr:full'] ??