Files
goodgo-platform/apps/web/components/neighborhood/neighborhood-poi-map.tsx
Ho Ngoc Hai 1668c800fe fix(web): resolve all 22 TypeScript typecheck errors in apps/web (TEC-3208)
- Fix TS4111: use bracket notation for index signature access in metadata.spec.ts,
  neighborhood-poi-map.tsx, and neighborhood-poi-map.spec.tsx
- Fix TS2740: add missing property fields (usableAreaM2, floor, totalFloors,
  nearbyPOIs, etc.) to test mock objects in 5 spec files
- Fix TS2339: add missing estimate() and create() methods to transferApi
- Fix TS4114: add override modifier to render() in page.tsx error boundary
- Fix TS2532: add optional chaining for possibly undefined features in
  neighborhood-poi-map.tsx

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 15:49:38 +07:00

396 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
'circle-color': [
'step',
['get', 'point_count'],
'hsl(var(--primary))',
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>
);
}