Files
goodgo-platform/apps/web/components/neighborhood/neighborhood-poi-map.tsx
Ho Ngoc Hai 405f2a3623
Some checks failed
Deploy / Build AI Services Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 10s
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 53s
Security Scanning / Trivy Filesystem Scan (push) Failing after 29s
CI / Lint → Typecheck → Test → Build (22) (push) Waiting to run
Security Scanning / Trivy Scan — Web Image (push) Failing after 42s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 34s
CI / E2E Tests (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
CI / AI Services (Python) — Smoke (push) Failing after 6s
Deploy / Smoke Test Production (push) Has been cancelled
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 5s
Deploy / Rollback Production (push) Has been cancelled
fix(web): neighborhood POI map — fix unparseable cluster color
Same Mapbox-gl issue as ListingMap: `hsl(var(--primary))` is rejected by
the GL color parser. Swap for a literal hex (#22c55e) matching the
design-system primary token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:57:12 +07:00

397 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
import { cn } from '@/lib/utils';
import { type POIItem, type POICategory, POI_CATEGORY_CONFIG } from './types';
// ── Mapbox layer IDs ──────────────────────────────────────────────────────────
const SOURCE_ID = 'poi-source';
const LAYER_CLUSTERS = 'poi-clusters';
const LAYER_CLUSTER_COUNT = 'poi-cluster-count';
const LAYER_UNCLUSTERED = 'poi-unclustered';
/**
* Color lookup per POI category — kept in sync with `POI_CATEGORY_CONFIG`.
* Used in Mapbox `match` expressions so the map layer drives coloring without
* requiring separate image assets for each category.
*/
const CATEGORY_COLORS: Record<POICategory, string> = {
school: '#3B82F6',
hospital: '#EF4444',
transit: '#8B5CF6',
shopping: '#F59E0B',
restaurant: '#F97316',
park: '#22C55E',
};
/** Build a GeoJSON FeatureCollection from `pois`, filtered to `activeCategories`. */
function buildGeoJson(
pois: POIItem[],
activeCategories: Set<POICategory>,
): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: pois
.filter((poi) => activeCategories.has(poi.category))
.map((poi) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [poi.lng, poi.lat] },
properties: {
id: poi.id,
name: poi.name,
category: poi.category,
categoryLabel: POI_CATEGORY_CONFIG[poi.category].label,
distance: poi.distance ?? null,
},
})),
};
}
interface NeighborhoodPOIMapProps {
center: { lat: number; lng: number };
pois: POIItem[];
zoom?: number;
height?: string;
className?: string;
}
export function NeighborhoodPOIMap({
center,
pois,
zoom = 14,
height = '400px',
className,
}: NeighborhoodPOIMapProps) {
const mapContainerRef = React.useRef<HTMLDivElement>(null);
const mapRef = React.useRef<mapboxgl.Map | null>(null);
const centerMarkerRef = React.useRef<mapboxgl.Marker | null>(null);
const mapStyle = useMapboxStyle();
const [mapLoaded, setMapLoaded] = React.useState(false);
const [activeCategories, setActiveCategories] = React.useState<Set<POICategory>>(
() => new Set(Object.keys(POI_CATEGORY_CONFIG) as POICategory[]),
);
const toggleCategory = React.useCallback((category: POICategory) => {
setActiveCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
}, []);
// ── Initialize map ──────────────────────────────────────────────────────────
React.useEffect(() => {
if (!mapContainerRef.current) return;
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
if (!token) return;
mapboxgl.accessToken = token;
const map = new mapboxgl.Map({
container: mapContainerRef.current,
style: mapStyle,
center: [center.lng, center.lat],
zoom,
attributionControl: false,
});
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right');
map.on('load', () => setMapLoaded(true));
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
setMapLoaded(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Re-apply style and rebuild state on theme change ────────────────────────
React.useEffect(() => {
const map = mapRef.current;
if (!map) return;
setMapLoaded(false);
map.setStyle(mapStyle);
const onStyleLoad = () => setMapLoaded(true);
map.once('style.load', onStyleLoad);
return () => {
map.off('style.load', onStyleLoad);
};
}, [mapStyle]);
// ── Fly to center when prop changes ─────────────────────────────────────────
React.useEffect(() => {
mapRef.current?.flyTo({ center: [center.lng, center.lat], zoom });
}, [center, zoom]);
// ── Property centre marker (DOM, single, no clustering) ─────────────────────
React.useEffect(() => {
const map = mapRef.current;
if (!map || !mapLoaded) return;
centerMarkerRef.current?.remove();
const el = document.createElement('div');
el.style.cssText = `
width: 16px; height: 16px; border-radius: 50%;
background: hsl(var(--primary));
border: 3px solid white;
box-shadow: 0 0 0 2px hsl(var(--primary)), 0 2px 8px rgba(0,0,0,0.3);
`;
centerMarkerRef.current = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([center.lng, center.lat])
.addTo(map);
return () => {
centerMarkerRef.current?.remove();
centerMarkerRef.current = null;
};
}, [mapLoaded, center]);
// ── POI GeoJSON source + cluster layers ─────────────────────────────────────
React.useEffect(() => {
const map = mapRef.current;
if (!map || !mapLoaded) return;
const geoJson = buildGeoJson(pois, activeCategories);
// If the source already exists (e.g. category toggle or pois prop update)
// just refresh the data — no need to recreate layers.
const existing = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
if (existing) {
existing.setData(geoJson);
return;
}
// ── GeoJSON source with built-in clustering ──────────────────────────────
map.addSource(SOURCE_ID, {
type: 'geojson',
data: geoJson,
cluster: true,
clusterMaxZoom: 13, // stop clustering above zoom 13
clusterRadius: 50, // pixels radius for merging
});
// ── Cluster bubble ───────────────────────────────────────────────────────
map.addLayer({
id: LAYER_CLUSTERS,
type: 'circle',
source: SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
// Small clusters: primary; medium: amber; large: red
// (Mapbox-gl's color parser rejects `hsl(var(--…))` — use literal hex)
'circle-color': [
'step',
['get', 'point_count'],
'#22c55e', // primary (emerald-500) — matches design-system token
5,
'#f59e0b',
20,
'#ef4444',
],
'circle-radius': [
'step',
['get', 'point_count'],
18, // < 5
5,
24, // 519
20,
32, // ≥ 20
],
'circle-stroke-width': 2,
'circle-stroke-color': 'white',
'circle-opacity': 0.9,
},
});
// ── Cluster count label ──────────────────────────────────────────────────
map.addLayer({
id: LAYER_CLUSTER_COUNT,
type: 'symbol',
source: SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12,
},
paint: {
'text-color': '#ffffff',
},
});
// ── Individual POI circle (unclustered) ──────────────────────────────────
map.addLayer({
id: LAYER_UNCLUSTERED,
type: 'circle',
source: SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 10,
'circle-color': [
'match',
['get', 'category'],
'school', CATEGORY_COLORS.school,
'hospital', CATEGORY_COLORS.hospital,
'transit', CATEGORY_COLORS.transit,
'shopping', CATEGORY_COLORS.shopping,
'restaurant', CATEGORY_COLORS.restaurant,
'park', CATEGORY_COLORS.park,
'#888888',
],
'circle-stroke-width': 2,
'circle-stroke-color': 'white',
'circle-opacity': 0.95,
},
});
// ── Click cluster → zoom in / expand ────────────────────────────────────
map.on('click', LAYER_CLUSTERS, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [LAYER_CLUSTERS] });
if (!features.length) return;
const clusterId = features[0]?.properties?.['cluster_id'] as number;
(map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
clusterId,
(err, expansionZoom) => {
if (err || expansionZoom == null) return;
const coords = (features[0]?.geometry as GeoJSON.Point).coordinates as [number, number];
map.easeTo({ center: coords, zoom: expansionZoom });
},
);
});
// ── Click unclustered POI → popup ────────────────────────────────────────
map.on('click', LAYER_UNCLUSTERED, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [LAYER_UNCLUSTERED] });
if (!features.length) return;
const { name, categoryLabel, distance } = features[0]?.properties ?? {};
const coords = (features[0]?.geometry as GeoJSON.Point).coordinates.slice() as [
number,
number,
];
new mapboxgl.Popup({ closeButton: true, closeOnClick: true, offset: 12 })
.setLngLat(coords)
.setHTML(
`<div style="font-family:system-ui,sans-serif;padding:8px 10px;border-radius:6px;">
<p style="font-weight:600;font-size:13px;margin:0 0 2px;">${name}</p>
<p style="font-size:12px;color:hsl(var(--muted-foreground));margin:0;">${categoryLabel}${distance ? ` · ${distance}m` : ''}</p>
</div>`,
)
.addTo(map);
});
// ── Cursor changes ───────────────────────────────────────────────────────
map.on('mouseenter', LAYER_CLUSTERS, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', LAYER_CLUSTERS, () => {
map.getCanvas().style.cursor = '';
});
map.on('mouseenter', LAYER_UNCLUSTERED, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', LAYER_UNCLUSTERED, () => {
map.getCanvas().style.cursor = '';
});
}, [mapLoaded, pois, activeCategories]);
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
const allCategories = Object.entries(POI_CATEGORY_CONFIG) as [
POICategory,
(typeof POI_CATEGORY_CONFIG)[POICategory],
][];
return (
<div className={cn('relative overflow-hidden rounded-lg border', className)}>
<div ref={mapContainerRef} style={{ height }} className="w-full" />
{/* Layer toggle controls */}
<div className="absolute left-3 top-3 flex flex-col gap-1.5">
{allCategories.map(([key, config]) => {
const isActive = activeCategories.has(key);
const poiCount = pois.filter((p) => p.category === key).length;
return (
<button
key={key}
type="button"
onClick={() => toggleCategory(key)}
className={cn(
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm transition-all',
isActive
? 'bg-card text-card-foreground ring-1 ring-inset ring-border'
: 'bg-card/60 text-muted-foreground line-through ring-1 ring-inset ring-transparent',
)}
title={`${isActive ? 'Ẩn' : 'Hiện'} ${config.label}`}
>
<config.icon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{config.label}</span>
{poiCount > 0 && (
<span
className={cn(
'ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold',
isActive
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground',
)}
>
{poiCount}
</span>
)}
</button>
);
})}
</div>
{/* Fallback when no Mapbox token */}
{!hasToken && (
<div
className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50"
style={{ height }}
>
<div className="text-center">
<svg
className="mx-auto mb-2 h-10 w-10 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<p className="text-sm text-muted-foreground">
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN đ hiển thị bản đ POI
</p>
</div>
</div>
)}
</div>
);
}