fix(industrial): improve OSM review UX + public map visibility
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
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>
This commit is contained in:
@@ -50,6 +50,10 @@ export class ListOsmPendingHandler implements IQueryHandler<ListOsmPendingQuery>
|
|||||||
conditions.push(`province = $${p++}`);
|
conditions.push(`province = $${p++}`);
|
||||||
values.push(q.province);
|
values.push(q.province);
|
||||||
}
|
}
|
||||||
|
if (q.region) {
|
||||||
|
conditions.push(`region::text = $${p++}`);
|
||||||
|
values.push(q.region);
|
||||||
|
}
|
||||||
if (q.query) {
|
if (q.query) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
`(name ILIKE $${p} OR "nameEn" ILIKE $${p} OR developer ILIKE $${p})`,
|
`(name ILIKE $${p} OR "nameEn" ILIKE $${p} OR developer ILIKE $${p})`,
|
||||||
@@ -57,6 +61,13 @@ export class ListOsmPendingHandler implements IQueryHandler<ListOsmPendingQuery>
|
|||||||
values.push(`%${q.query}%`);
|
values.push(`%${q.query}%`);
|
||||||
p += 1;
|
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 where = conditions.join(' AND ');
|
||||||
|
|
||||||
const [{ count }] = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>(
|
const [{ count }] = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>(
|
||||||
@@ -95,7 +106,7 @@ export class ListOsmPendingHandler implements IQueryHandler<ListOsmPendingQuery>
|
|||||||
ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng
|
ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng
|
||||||
FROM "IndustrialPark"
|
FROM "IndustrialPark"
|
||||||
WHERE ${where}
|
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}`,
|
LIMIT $${p++} OFFSET $${p}`,
|
||||||
...values,
|
...values,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Admin OSM review queue — list raw OSM-imported parks that haven't yet
|
* Admin OSM review queue — list raw OSM-imported parks that haven't yet
|
||||||
* been promoted to the public catalogue.
|
* 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 {
|
export class ListOsmPendingQuery {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -8,5 +13,7 @@ export class ListOsmPendingQuery {
|
|||||||
public readonly limit: number = 50,
|
public readonly limit: number = 50,
|
||||||
public readonly query?: string,
|
public readonly query?: string,
|
||||||
public readonly province?: string,
|
public readonly province?: string,
|
||||||
|
public readonly minAreaHa: number = 50,
|
||||||
|
public readonly region?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,8 @@ export class IndustrialParksController {
|
|||||||
@Query('limit') limit?: string,
|
@Query('limit') limit?: string,
|
||||||
@Query('q') q?: string,
|
@Query('q') q?: string,
|
||||||
@Query('province') province?: string,
|
@Query('province') province?: string,
|
||||||
|
@Query('minAreaHa') minAreaHa?: string,
|
||||||
|
@Query('region') region?: string,
|
||||||
) {
|
) {
|
||||||
return this.queryBus.execute(
|
return this.queryBus.execute(
|
||||||
new ListOsmPendingQuery(
|
new ListOsmPendingQuery(
|
||||||
@@ -309,6 +311,8 @@ export class IndustrialParksController {
|
|||||||
limit ? parseInt(limit, 10) : 50,
|
limit ? parseInt(limit, 10) : 50,
|
||||||
q,
|
q,
|
||||||
province,
|
province,
|
||||||
|
minAreaHa !== undefined ? Number(minAreaHa) : 50,
|
||||||
|
region,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ import {
|
|||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import {
|
import {
|
||||||
industrialApi,
|
industrialApi,
|
||||||
|
REGION_LABELS,
|
||||||
type OsmPendingItem,
|
type OsmPendingItem,
|
||||||
type OsmPendingResult,
|
type OsmPendingResult,
|
||||||
|
type VietnamRegion,
|
||||||
} from '@/lib/khu-cong-nghiep-api';
|
} from '@/lib/khu-cong-nghiep-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +58,17 @@ import {
|
|||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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 }[] = [
|
const QUICK_LOCK_FIELDS: { key: string; label: string }[] = [
|
||||||
{ key: 'name', label: 'Tên KCN' },
|
{ key: 'name', label: 'Tên KCN' },
|
||||||
{ key: 'developer', label: 'Chủ đầu tư' },
|
{ key: 'developer', label: 'Chủ đầu tư' },
|
||||||
@@ -91,6 +104,8 @@ export default function AdminOsmReviewPage() {
|
|||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [provinceFilter, setProvinceFilter] = useState('');
|
const [provinceFilter, setProvinceFilter] = useState('');
|
||||||
|
const [minAreaHa, setMinAreaHa] = useState<number>(50);
|
||||||
|
const [region, setRegion] = useState<VietnamRegion | ''>('');
|
||||||
|
|
||||||
// Promote dialog state
|
// Promote dialog state
|
||||||
const [promoteTarget, setPromoteTarget] = useState<OsmPendingItem | null>(null);
|
const [promoteTarget, setPromoteTarget] = useState<OsmPendingItem | null>(null);
|
||||||
@@ -107,6 +122,8 @@ export default function AdminOsmReviewPage() {
|
|||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
q: search || undefined,
|
q: search || undefined,
|
||||||
province: provinceFilter || undefined,
|
province: provinceFilter || undefined,
|
||||||
|
minAreaHa,
|
||||||
|
region: region || undefined,
|
||||||
});
|
});
|
||||||
setResult(data);
|
setResult(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -114,7 +131,7 @@ export default function AdminOsmReviewPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search, provinceFilter]);
|
}, [page, search, provinceFilter, minAreaHa, region]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchQueue();
|
fetchQueue();
|
||||||
@@ -218,6 +235,43 @@ export default function AdminOsmReviewPage() {
|
|||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<label className="mb-1 block text-xs text-foreground-dim">
|
||||||
|
Diện tích tối thiểu
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={minAreaHa}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setMinAreaHa(Number(e.target.value));
|
||||||
|
}}
|
||||||
|
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
{MIN_AREA_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<label className="mb-1 block text-xs text-foreground-dim">Vùng miền</label>
|
||||||
|
<select
|
||||||
|
value={region}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setRegion((e.target.value as VietnamRegion) || '');
|
||||||
|
}}
|
||||||
|
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Tất cả</option>
|
||||||
|
{(Object.keys(REGION_LABELS) as VietnamRegion[]).map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{REGION_LABELS[r]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="w-40">
|
<div className="w-40">
|
||||||
<label className="mb-1 block text-xs text-foreground-dim">Tỉnh / TP</label>
|
<label className="mb-1 block text-xs text-foreground-dim">Tỉnh / TP</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -234,7 +288,7 @@ export default function AdminOsmReviewPage() {
|
|||||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Tìm
|
Tìm
|
||||||
</Button>
|
</Button>
|
||||||
{(search || provinceFilter) && (
|
{(search || provinceFilter || region || minAreaHa !== 50) && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -243,11 +297,13 @@ export default function AdminOsmReviewPage() {
|
|||||||
setSearchInput('');
|
setSearchInput('');
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setProvinceFilter('');
|
setProvinceFilter('');
|
||||||
|
setRegion('');
|
||||||
|
setMinAreaHa(50);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="mr-1 h-3 w-3" />
|
<X className="mr-1 h-3 w-3" />
|
||||||
Xóa bộ lọc
|
Đặt lại
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
@@ -444,8 +500,9 @@ export default function AdminOsmReviewPage() {
|
|||||||
<span className="flex items-start gap-2 text-sm">
|
<span className="flex items-start gap-2 text-sm">
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
KCN <strong>{promoteTarget?.name}</strong> sẽ được chuyển sang trạng thái public
|
Sắp promote: <strong>{promoteTarget?.name}</strong>. KCN sẽ chuyển sang trạng thái
|
||||||
(OSM_PROMOTED). Chọn các trường muốn khóa để bảo vệ chúng khỏi OSM sync sau này.
|
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.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Factory, Map as MapIcon, List, Columns } from 'lucide-react';
|
import { Factory, Map as MapIcon, List, Columns } from 'lucide-react';
|
||||||
import * as React from '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 { OsmParkBboxMap } from '@/components/khu-cong-nghiep/osm-park-bbox-map';
|
||||||
import { ParkCard } from '@/components/khu-cong-nghiep/park-card';
|
import { ParkCard } from '@/components/khu-cong-nghiep/park-card';
|
||||||
import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar';
|
import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar';
|
||||||
@@ -19,6 +20,10 @@ export default function KhuCongNghiepPage() {
|
|||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const [viewMode, setViewMode] = React.useState<ViewMode>('split');
|
const [viewMode, setViewMode] = React.useState<ViewMode>('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);
|
const { data, isLoading, isError } = useIndustrialParksSearch(filters);
|
||||||
|
|
||||||
@@ -113,7 +118,16 @@ export default function KhuCongNghiepPage() {
|
|||||||
|
|
||||||
{/* Map-only view — bbox-driven, loads ALL parks in viewport */}
|
{/* Map-only view — bbox-driven, loads ALL parks in viewport */}
|
||||||
{viewMode === 'map' && (
|
{viewMode === 'map' && (
|
||||||
<OsmParkBboxMap className="h-[calc(100vh-260px)]" />
|
<>
|
||||||
|
<OsmMapLegend
|
||||||
|
includeOsmRaw={includeOsmRaw}
|
||||||
|
onToggleOsmRaw={setIncludeOsmRaw}
|
||||||
|
/>
|
||||||
|
<OsmParkBboxMap
|
||||||
|
className="h-[calc(100vh-300px)]"
|
||||||
|
includeOsmRaw={includeOsmRaw}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Split view: list left, sticky bbox map right (lg+ only) */}
|
{/* Split view: list left, sticky bbox map right (lg+ only) */}
|
||||||
@@ -126,8 +140,16 @@ export default function KhuCongNghiepPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:flex lg:flex-col lg:gap-2">
|
||||||
<OsmParkBboxMap className="sticky top-20 h-[calc(100vh-220px)]" />
|
<OsmMapLegend
|
||||||
|
includeOsmRaw={includeOsmRaw}
|
||||||
|
onToggleOsmRaw={setIncludeOsmRaw}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<OsmParkBboxMap
|
||||||
|
className="sticky top-20 h-[calc(100vh-260px)]"
|
||||||
|
includeOsmRaw={includeOsmRaw}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
67
apps/web/components/khu-cong-nghiep/osm-map-legend.tsx
Normal file
67
apps/web/components/khu-cong-nghiep/osm-map-legend.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-3 rounded-lg border bg-card ${
|
||||||
|
compact ? 'px-3 py-2 text-xs' : 'px-4 py-2.5 text-sm'
|
||||||
|
}`}
|
||||||
|
role="group"
|
||||||
|
aria-label="Chú giải bản đồ KCN"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: '#22c55e' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="text-foreground">KCN đã xác minh</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-full border-2 border-white shadow opacity-70"
|
||||||
|
style={{ backgroundColor: '#f59e0b' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="text-foreground">KCN từ OpenStreetMap (chưa duyệt)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="ml-auto flex cursor-pointer items-center gap-1.5 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeOsmRaw}
|
||||||
|
onChange={(e) => onToggleOsmRaw(e.target.checked)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<span>Hiển thị KCN OSM</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{includeOsmRaw && !compact && (
|
||||||
|
<p className="flex w-full items-center gap-1.5 border-t border-border pt-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3 w-3 shrink-0" />
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,8 +50,12 @@ export function OsmParkBboxMap({
|
|||||||
// Capture the current includeOsmRaw value via a ref so the moveend
|
// Capture the current includeOsmRaw value via a ref so the moveend
|
||||||
// handler always sees the latest without re-binding the listener.
|
// handler always sees the latest without re-binding the listener.
|
||||||
const includeOsmRawRef = React.useRef(includeOsmRaw);
|
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(() => {
|
React.useEffect(() => {
|
||||||
includeOsmRawRef.current = includeOsmRaw;
|
includeOsmRawRef.current = includeOsmRaw;
|
||||||
|
refetchTokenRef.current?.();
|
||||||
}, [includeOsmRaw]);
|
}, [includeOsmRaw]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -166,6 +170,8 @@ export function OsmParkBboxMap({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Individual park markers (centroid Points) when not clustered.
|
// 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({
|
map.addLayer({
|
||||||
id: POINT_LAYER_ID,
|
id: POINT_LAYER_ID,
|
||||||
type: 'circle',
|
type: 'circle',
|
||||||
@@ -176,10 +182,26 @@ export function OsmParkBboxMap({
|
|||||||
['==', ['get', '_kind'], 'point'],
|
['==', ['get', '_kind'], 'point'],
|
||||||
],
|
],
|
||||||
paint: {
|
paint: {
|
||||||
'circle-color': '#22c55e',
|
'circle-color': [
|
||||||
'circle-radius': 6,
|
'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-color': '#ffffff',
|
||||||
'circle-stroke-width': 1.5,
|
'circle-stroke-width': 1.5,
|
||||||
|
'circle-opacity': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'dataSource'], 'OSM'],
|
||||||
|
0.7,
|
||||||
|
1,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,8 +213,18 @@ export function OsmParkBboxMap({
|
|||||||
source: SOURCE_ID,
|
source: SOURCE_ID,
|
||||||
filter: ['==', ['get', '_kind'], 'polygon'],
|
filter: ['==', ['get', '_kind'], 'polygon'],
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': '#22c55e',
|
'fill-color': [
|
||||||
'fill-opacity': 0.18,
|
'case',
|
||||||
|
['==', ['get', 'dataSource'], 'OSM'],
|
||||||
|
'#f59e0b',
|
||||||
|
'#22c55e',
|
||||||
|
],
|
||||||
|
'fill-opacity': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'dataSource'], 'OSM'],
|
||||||
|
0.1,
|
||||||
|
0.18,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@@ -201,9 +233,19 @@ export function OsmParkBboxMap({
|
|||||||
source: SOURCE_ID,
|
source: SOURCE_ID,
|
||||||
filter: ['==', ['get', '_kind'], 'polygon'],
|
filter: ['==', ['get', '_kind'], 'polygon'],
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#22c55e',
|
'line-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'dataSource'], 'OSM'],
|
||||||
|
'#f59e0b',
|
||||||
|
'#22c55e',
|
||||||
|
],
|
||||||
'line-width': 2,
|
'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.
|
// Initial fetch + listen to viewport changes.
|
||||||
void fetchParks();
|
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', () => {
|
map.on('moveend', () => {
|
||||||
|
|||||||
@@ -271,6 +271,9 @@ export interface ListOsmPendingParams {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
q?: string;
|
q?: string;
|
||||||
province?: 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 ─────────────────────────────────────────────
|
// ─── Labels ─────────────────────────────────────────────
|
||||||
|
|||||||
94
scripts/backfill-osm-provinces.ts
Normal file
94
scripts/backfill-osm-provinces.ts
Normal file
@@ -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<void> {
|
||||||
|
console.log('🔍 Finding OSM rows with province="Chưa xác định"…');
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(
|
||||||
|
`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<string, string[]>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
107
scripts/data/vn-province-centroids.ts
Normal file
107
scripts/data/vn-province-centroids.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,13 +25,14 @@
|
|||||||
*/
|
*/
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import area from '@turf/area';
|
|
||||||
import centroid from '@turf/centroid';
|
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { type Prisma, PrismaClient } from '@prisma/client';
|
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 type { Feature, MultiPolygon, Polygon, Point } from 'geojson';
|
||||||
import osmtogeojson from 'osmtogeojson';
|
import osmtogeojson from 'osmtogeojson';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
import { nearestProvince } from './data/vn-province-centroids';
|
||||||
|
|
||||||
const generateCuid = (): Promise<string> => Promise.resolve(createId());
|
const generateCuid = (): Promise<string> => Promise.resolve(createId());
|
||||||
|
|
||||||
@@ -200,10 +201,14 @@ function parseFeature(
|
|||||||
totalAreaHa = Math.round((area(feat as Feature) / 10000) * 100) / 100;
|
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 =
|
const province =
|
||||||
VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ??
|
VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ??
|
||||||
tags['addr:city'] ??
|
tags['addr:city'] ??
|
||||||
'Chưa xác định';
|
nearestProvince(cLat, cLng);
|
||||||
const district = tags['addr:district'] ?? tags['addr:suburb'] ?? '';
|
const district = tags['addr:district'] ?? tags['addr:suburb'] ?? '';
|
||||||
const address =
|
const address =
|
||||||
tags['addr:full'] ??
|
tags['addr:full'] ??
|
||||||
|
|||||||
Reference in New Issue
Block a user