Improve map
This commit is contained in:
parent
400f733956
commit
51967fa880
7 changed files with 794 additions and 353 deletions
|
|
@ -13,169 +13,24 @@ interface MapProps {
|
|||
pois: POI[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
onHexagonClick: (h3: string) => void;
|
||||
}
|
||||
|
||||
// Twemoji CDN base URL
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
// Map category to Twemoji codepoint (emoji unicode -> hex)
|
||||
const POI_EMOJI_CODES: Record<string, string> = {
|
||||
// Education
|
||||
school: '1f3eb', // 🏫
|
||||
preschool: '1f476', // 👶
|
||||
college_university: '1f393', // 🎓
|
||||
library: '1f4da', // 📚
|
||||
// Healthcare
|
||||
doctor: '1f3e5', // 🏥
|
||||
dentist: '1f9b7', // 🦷
|
||||
pharmacy: '1f48a', // 💊
|
||||
hospital: '1f3e5',
|
||||
public_health_clinic: '1f3e5',
|
||||
veterinary: '1f43e', // 🐾
|
||||
nursing_home: '1f3e0', // 🏠
|
||||
social_facility: '1f91d', // 🤝
|
||||
// Transport
|
||||
train_station: '1f689', // 🚉
|
||||
bus_station: '1f68c', // 🚌
|
||||
bus_stop: '1f68f', // 🚏
|
||||
metro_station: '1f687', // 🚇
|
||||
light_rail_station: '1f687',
|
||||
tram_stop: '1f68a', // 🚊
|
||||
ferry_terminal: '26f4', // ⛴
|
||||
airport: '2708', // ✈
|
||||
// Parks & Leisure
|
||||
park: '1f333', // 🌳
|
||||
national_park: '1f3de', // 🏞
|
||||
nature_reserve: '1f33f', // 🌿
|
||||
dog_park: '1f415', // 🐕
|
||||
playground: '1f3a0', // 🎠
|
||||
garden: '1f33a', // 🌺
|
||||
sports_centre: '1f3c3', // 🏃
|
||||
swimming_pool: '1f3ca', // 🏊
|
||||
gym: '1f4aa', // 💪
|
||||
golf_course: '26f3', // ⛳
|
||||
marina: '26f5', // ⛵
|
||||
// Emergency
|
||||
police_department: '1f694', // 🚔
|
||||
fire_department: '1f692', // 🚒
|
||||
// Supermarkets & Grocery
|
||||
supermarket: '1f6d2', // 🛒
|
||||
grocery_store: '1f6d2',
|
||||
convenience_store: '1f3ea', // 🏪
|
||||
bakery: '1f35e', // 🍞
|
||||
butcher: '1f969', // 🥩
|
||||
greengrocer: '1f966', // 🥦
|
||||
deli: '1f9c0', // 🧀
|
||||
// Shopping
|
||||
department_store: '1f3ec', // 🏬
|
||||
clothing_store: '1f455', // 👕
|
||||
shoe_store: '1f45f', // 👟
|
||||
electronics_store: '1f4f1', // 📱
|
||||
hardware_store: '1f527', // 🔧
|
||||
furniture_store: '1fa91', // 🪑
|
||||
bookshop: '1f4d6', // 📖
|
||||
newsagent: '1f4f0', // 📰
|
||||
charity_shop: '1f49c', // 💜
|
||||
shopping_centre: '1f6cd', // 🛍
|
||||
optician: '1f453', // 👓
|
||||
off_licence: '1f37a', // 🍺
|
||||
// Food & Drink
|
||||
restaurant: '1f37d', // 🍽
|
||||
cafe: '2615', // ☕
|
||||
pub: '1f37b', // 🍻
|
||||
bar: '1f378', // 🍸
|
||||
fast_food: '1f354', // 🍔
|
||||
food_court: '1f372', // 🍲
|
||||
ice_cream: '1f366', // 🍦
|
||||
beer_garden: '1f37a', // 🍺
|
||||
// Personal Care
|
||||
hairdresser: '1f487', // 💇
|
||||
beauty_salon: '1f484', // 💄
|
||||
laundry: '1f9fa', // 🧺
|
||||
dry_cleaning: '1f455', // 👕
|
||||
// Finance
|
||||
bank: '1f3e6', // 🏦
|
||||
atm: '1f4b3', // 💳
|
||||
bureau_de_change: '1f4b1', // 💱
|
||||
// Entertainment & Culture
|
||||
cinema: '1f3ac', // 🎬
|
||||
theatre: '1f3ad', // 🎭
|
||||
nightclub: '1f483', // 💃
|
||||
community_centre: '1f3db', // 🏛
|
||||
arts_centre: '1f3a8', // 🎨
|
||||
museum: '1f3db', // 🏛
|
||||
gallery: '1f5bc', // 🖼
|
||||
attraction: '2b50', // ⭐
|
||||
zoo: '1f418', // 🐘
|
||||
theme_park: '1f3a2', // 🎢
|
||||
viewpoint: '1f301', // 🌁
|
||||
// Accommodation
|
||||
hotel: '1f3e8', // 🏨
|
||||
hostel: '1f6cf', // 🛏
|
||||
guest_house: '1f3e1', // 🏡
|
||||
campsite: '26fa', // ⛺
|
||||
caravan_site: '1f699', // 🚙
|
||||
// Religion
|
||||
place_of_worship: '1f6d0', // 🛐
|
||||
// Government & Public
|
||||
town_hall: '1f3db', // 🏛
|
||||
courthouse: '2696', // ⚖
|
||||
post_office: '1f4ee', // 📮
|
||||
prison: '1f513', // 🔓
|
||||
public_toilets: '1f6bb', // 🚻
|
||||
// Automotive
|
||||
petrol_station: '26fd', // ⛽
|
||||
ev_charging: '1f50c', // 🔌
|
||||
car_dealer: '1f697', // 🚗
|
||||
car_repair: '1f527', // 🔧
|
||||
parking: '1f17f', // 🅿
|
||||
bicycle_parking: '1f6b2', // 🚲
|
||||
// Recycling & Waste
|
||||
recycling: '267b', // ♻
|
||||
waste_disposal: '1f5d1', // 🗑
|
||||
};
|
||||
|
||||
function getPOIIconUrl(category: string): string {
|
||||
const code = POI_EMOJI_CODES[category] || '1f4cd'; // 📍 default
|
||||
return `${TWEMOJI_BASE}${code}.png`;
|
||||
// Convert emoji to Twemoji URL
|
||||
function emojiToTwemojiUrl(emoji: string): string {
|
||||
// Convert emoji to Unicode codepoint hex
|
||||
const codePoint = emoji.codePointAt(0);
|
||||
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`; // Default pin
|
||||
const hex = codePoint.toString(16);
|
||||
return `${TWEMOJI_BASE}${hex}.png`;
|
||||
}
|
||||
|
||||
// Tooltip emojis (these render fine in HTML)
|
||||
const TOOLTIP_EMOJIS: Record<string, string> = {
|
||||
school: '🏫', preschool: '👶', college_university: '🎓', library: '📚',
|
||||
doctor: '🏥', dentist: '🦷', pharmacy: '💊', hospital: '🏥',
|
||||
public_health_clinic: '🏥', veterinary: '🐾', nursing_home: '🏠', social_facility: '🤝',
|
||||
train_station: '🚉', bus_station: '🚌', bus_stop: '🚏', metro_station: '🚇',
|
||||
light_rail_station: '🚇', tram_stop: '🚊', ferry_terminal: '⛴️', airport: '✈️',
|
||||
park: '🌳', national_park: '🏞️', nature_reserve: '🌿', dog_park: '🐕',
|
||||
playground: '🎠', garden: '🌺', sports_centre: '🏃', swimming_pool: '🏊',
|
||||
gym: '💪', golf_course: '⛳', marina: '⛵',
|
||||
police_department: '🚔', fire_department: '🚒',
|
||||
supermarket: '🛒', grocery_store: '🛒', convenience_store: '🏪',
|
||||
bakery: '🍞', butcher: '🥩', greengrocer: '🥦', deli: '🧀',
|
||||
department_store: '🏬', clothing_store: '👕', shoe_store: '👟',
|
||||
electronics_store: '📱', hardware_store: '🔧', furniture_store: '🪑',
|
||||
bookshop: '📖', newsagent: '📰', charity_shop: '💜', shopping_centre: '🛍️',
|
||||
optician: '👓', off_licence: '🍺',
|
||||
restaurant: '🍽️', cafe: '☕', pub: '🍻', bar: '🍸',
|
||||
fast_food: '🍔', food_court: '🍲', ice_cream: '🍦', beer_garden: '🍺',
|
||||
hairdresser: '💇', beauty_salon: '💄', laundry: '🧺', dry_cleaning: '👕',
|
||||
bank: '🏦', atm: '💳', bureau_de_change: '💱',
|
||||
cinema: '🎬', theatre: '🎭', nightclub: '💃', community_centre: '🏛️',
|
||||
arts_centre: '🎨', museum: '🏛️', gallery: '🖼️', attraction: '⭐',
|
||||
zoo: '🐘', theme_park: '🎢', viewpoint: '🌁',
|
||||
hotel: '🏨', hostel: '🛏️', guest_house: '🏡', campsite: '⛺', caravan_site: '🚙',
|
||||
place_of_worship: '🛐',
|
||||
town_hall: '🏛️', courthouse: '⚖️', post_office: '📮', prison: '🔓', public_toilets: '🚻',
|
||||
petrol_station: '⛽', ev_charging: '🔌', car_dealer: '🚗', car_repair: '🔧',
|
||||
parking: '🅿️', bicycle_parking: '🚲',
|
||||
recycling: '♻️', waste_disposal: '🗑️',
|
||||
};
|
||||
|
||||
function getTooltipEmoji(category: string): string {
|
||||
return TOOLTIP_EMOJIS[category] || '📍';
|
||||
}
|
||||
|
||||
const INITIAL_VIEW: ViewState = {
|
||||
longitude: -1.5,
|
||||
|
|
@ -280,7 +135,16 @@ function DeckOverlay({
|
|||
return null;
|
||||
}
|
||||
|
||||
export default function Map({ data, pois, onViewChange, activeFeature, features }: MapProps) {
|
||||
// Sequential blue scale for count-based coloring
|
||||
function countToColor(t: number): [number, number, number] {
|
||||
// light blue (209, 226, 243) -> dark blue (33, 102, 172)
|
||||
const r = Math.round(209 + (33 - 209) * t);
|
||||
const g = Math.round(226 + (102 - 226) * t);
|
||||
const b = Math.round(243 + (172 - 243) * t);
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
export default function Map({ data, pois, onViewChange, activeFeature, dragValue, features, selectedHexagonId, onHexagonClick }: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
|
@ -347,9 +211,31 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Determine which feature to use for coloring
|
||||
const colorFeatureName = activeFeature || (features.length > 0 ? features[0].name : null);
|
||||
const colorFeatureMeta = features.find((f) => f.name === colorFeatureName) || null;
|
||||
// Compute count range for count-based coloring
|
||||
const countRange = useMemo(() => {
|
||||
if (data.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of data) {
|
||||
const c = d.count as number;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [data]);
|
||||
|
||||
// Determine color mode
|
||||
const colorFeatureMeta = activeFeature ? features.find((f) => f.name === activeFeature) || null : null;
|
||||
|
||||
const handleHexagonClick = useCallback(
|
||||
(info: PickingInfo<HexagonData>) => {
|
||||
if (info.object && 'h3' in info.object) {
|
||||
onHexagonClick(info.object.h3);
|
||||
}
|
||||
},
|
||||
[onHexagonClick]
|
||||
);
|
||||
|
||||
const layers = useMemo(
|
||||
() => [
|
||||
|
|
@ -358,21 +244,33 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
|||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
if (!colorFeatureName || !colorFeatureMeta) return [128, 128, 128] as [number, number, number];
|
||||
const val = d[`min_${colorFeatureName}`];
|
||||
if (val == null) return [128, 128, 128] as [number, number, number];
|
||||
const range = colorFeatureMeta.max - colorFeatureMeta.min;
|
||||
if (range === 0) return GRADIENT[0].color;
|
||||
const t = ((val as number) - colorFeatureMeta.min) / range;
|
||||
return normalizedToColor(t);
|
||||
if (activeFeature && dragValue && colorFeatureMeta) {
|
||||
// Drag mode: color by feature value using gradient
|
||||
const val = d[`min_${activeFeature}`];
|
||||
if (val == null) return [128, 128, 128] as [number, number, number];
|
||||
const range = dragValue[1] - dragValue[0];
|
||||
if (range === 0) return GRADIENT[0].color;
|
||||
const t = ((val as number) - dragValue[0]) / range;
|
||||
return normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
}
|
||||
// Normal mode: color by count using blue scale
|
||||
const c = d.count as number;
|
||||
const t = (c - countRange.min) / (countRange.max - countRange.min);
|
||||
return countToColor(Math.max(0, Math.min(1, t)));
|
||||
},
|
||||
getLineColor: (d) => (d.h3 === selectedHexagonId ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [number, number, number, number],
|
||||
getLineWidth: (d) => (d.h3 === selectedHexagonId ? 2 : 0),
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [colorFeatureName, colorFeatureMeta],
|
||||
getFillColor: [activeFeature, dragValue, countRange, colorFeatureMeta],
|
||||
getLineColor: [selectedHexagonId],
|
||||
getLineWidth: [selectedHexagonId],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
opacity: 0.5,
|
||||
highPrecision: true,
|
||||
onClick: handleHexagonClick,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: LABEL_LAYER_ID,
|
||||
}),
|
||||
|
|
@ -381,7 +279,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
|||
data: pois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: getPOIIconUrl(d.category),
|
||||
url: emojiToTwemojiUrl(d.emoji),
|
||||
width: 72,
|
||||
height: 72,
|
||||
}),
|
||||
|
|
@ -392,7 +290,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
|||
onHover: handlePoiHover,
|
||||
}),
|
||||
],
|
||||
[data, pois, handlePoiHover, colorFeatureName, colorFeatureMeta]
|
||||
[data, pois, handlePoiHover, handleHexagonClick, activeFeature, dragValue, countRange, colorFeatureMeta, selectedHexagonId]
|
||||
);
|
||||
|
||||
const getTooltip = useCallback(
|
||||
|
|
@ -409,7 +307,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
|||
if (minVal != null && maxVal != null) {
|
||||
const minStr = typeof minVal === 'number' ? minVal.toLocaleString(undefined, { maximumFractionDigits: 1 }) : String(minVal);
|
||||
const maxStr = typeof maxVal === 'number' ? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 }) : String(maxVal);
|
||||
const highlight = f.name === colorFeatureName ? 'font-weight: bold;' : '';
|
||||
const highlight = f.name === activeFeature ? 'font-weight: bold;' : '';
|
||||
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
|
||||
}
|
||||
}
|
||||
|
|
@ -423,7 +321,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
|||
},
|
||||
};
|
||||
},
|
||||
[features, colorFeatureName]
|
||||
[features, activeFeature]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -434,6 +332,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
|||
onLoad={handleMapLoad as never}
|
||||
mapStyle={MAP_STYLE}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
>
|
||||
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
||||
</MapGL>
|
||||
|
|
@ -447,10 +346,8 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
|||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
{getTooltipEmoji(popupInfo.category)} {popupInfo.name}
|
||||
</strong>
|
||||
<div className="text-gray-500 text-xs">{popupInfo.category.replace(/_/g, ' ')}</div>
|
||||
<strong>{popupInfo.name}</strong>
|
||||
<div className="text-gray-500 text-xs">{popupInfo.category}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue