From e7511194025937865e99b54b614ba128b332569b Mon Sep 17 00:00:00 2001 From: Ben Furber Date: Tue, 7 Jul 2026 15:15:05 +0100 Subject: [PATCH] feat: add marker styling for selected pin --- .../src/CardListItem/CardListItem.stories.tsx | 2 + .../CreateComment/CreateComment.stories.tsx | 1 + .../src/CreateComment/CreateComment.test.tsx | 1 + .../src/MemberBadge/MemberBadge.stories.tsx | 2 + packages/components/src/utils.ts | 1 + seed.ts | 5 ++ shared/mocks/data/profileTypes.ts | 5 ++ shared/models/profileType.ts | 3 ++ .../Maps/Content/MapView/Cluster.client.tsx | 50 +++++++++++++++++-- .../Maps/Content/MapView/MapView.client.tsx | 7 ++- .../Maps/Content/MapView/Markers.client.tsx | 7 ++- src/pages/Maps/Content/MapView/Sprites.tsx | 20 +++++--- src/pages/Maps/Content/MapView/sprites.css | 22 +++++--- .../sections/ProfileType.section.test.tsx | 3 ++ src/routes/api.map-pin.ts | 1 + src/routes/api.map-pins.$userId.ts | 1 + src/services/mapPinsService.server.ts | 1 + src/services/mapService.server.ts | 2 + src/services/profileService.server.ts | 4 ++ src/services/profileTypesService.server.ts | 1 + src/test/factories/User.ts | 1 + src/test/factories/profile.ts | 1 + ..._add_background_color_to_profile_types.sql | 1 + supabase/schemas/profiles.sql | 1 + 24 files changed, 122 insertions(+), 21 deletions(-) create mode 100644 supabase/migrations/20260707140410_add_background_color_to_profile_types.sql diff --git a/packages/components/src/CardListItem/CardListItem.stories.tsx b/packages/components/src/CardListItem/CardListItem.stories.tsx index 4c28cfe4c9..99d0331673 100644 --- a/packages/components/src/CardListItem/CardListItem.stories.tsx +++ b/packages/components/src/CardListItem/CardListItem.stories.tsx @@ -15,6 +15,7 @@ const viewport = 'desktop'; const member: ProfileType = { name: 'member', + backgroundColor: '#f090b3', description: 'A member profile', displayName: 'Member', id: 2, @@ -27,6 +28,7 @@ const member: ProfileType = { const space: ProfileType = { name: 'space', + backgroundColor: '#f090b3', description: 'A space profile', displayName: 'Space', id: 3, diff --git a/packages/components/src/CreateComment/CreateComment.stories.tsx b/packages/components/src/CreateComment/CreateComment.stories.tsx index 165fdcb223..1b10089ec6 100644 --- a/packages/components/src/CreateComment/CreateComment.stories.tsx +++ b/packages/components/src/CreateComment/CreateComment.stories.tsx @@ -13,6 +13,7 @@ export default { const member: ProfileType = { name: 'member', + backgroundColor: '#f090b3', description: 'A member profile', displayName: 'Member', id: 2, diff --git a/packages/components/src/CreateComment/CreateComment.test.tsx b/packages/components/src/CreateComment/CreateComment.test.tsx index 832f95fcc8..abae7e4bb7 100644 --- a/packages/components/src/CreateComment/CreateComment.test.tsx +++ b/packages/components/src/CreateComment/CreateComment.test.tsx @@ -11,6 +11,7 @@ import type { ProfileType } from 'oa-shared'; const member: ProfileType = { name: 'member', + backgroundColor: '#f090b3', description: 'A member profile', displayName: 'Member', id: 2, diff --git a/packages/components/src/MemberBadge/MemberBadge.stories.tsx b/packages/components/src/MemberBadge/MemberBadge.stories.tsx index e934d58e08..b34297cf82 100644 --- a/packages/components/src/MemberBadge/MemberBadge.stories.tsx +++ b/packages/components/src/MemberBadge/MemberBadge.stories.tsx @@ -15,6 +15,7 @@ export default { } as Meta; const machineBuilder: ProfileType = { name: 'machine-builder', + backgroundColor: '#f29195', description: 'A machine builder profile', displayName: 'Machine Builder', id: 1, @@ -26,6 +27,7 @@ const machineBuilder: ProfileType = { }; const member: ProfileType = { name: 'member', + backgroundColor: '#f090b3', description: 'A member profile', displayName: 'Member', id: 2, diff --git a/packages/components/src/utils.ts b/packages/components/src/utils.ts index 74616bccf9..f29d2b7b7d 100644 --- a/packages/components/src/utils.ts +++ b/packages/components/src/utils.ts @@ -175,6 +175,7 @@ export const fakeResearchUpdate = ( export const fakeProfileType = (profileTypeOverLoads: Partial = {}): ProfileType => ({ id: faker.number.int(), + backgroundColor: '#f090b3', description: '', displayName: '', imageUrl: faker.image.avatar(), diff --git a/seed.ts b/seed.ts index 113baa62c1..501de45b6e 100644 --- a/seed.ts +++ b/seed.ts @@ -106,6 +106,7 @@ const seedTags = (): tagsChildInputs => [{ ..._TAGS_BASE, name: 'tag 1' }]; const seedProfileTypes = (): Partial[] => [ { ..._TYPES_BASE, + background_color: '#f090b3', name: 'member', display_name: 'Member', is_space: false, @@ -119,6 +120,7 @@ const seedProfileTypes = (): Partial[] => [ }, { ..._TYPES_BASE, + background_color: '#97cdeb', name: 'workspace', display_name: 'Workspace', is_space: true, @@ -132,6 +134,7 @@ const seedProfileTypes = (): Partial[] => [ }, { ..._TYPES_BASE, + background_color: '#f29195', name: 'machine-builder', display_name: 'Machine Builder', is_space: true, @@ -145,6 +148,7 @@ const seedProfileTypes = (): Partial[] => [ }, { ..._TYPES_BASE, + background_color: '#8ec685', name: 'community-point', display_name: 'Community Point', is_space: true, @@ -158,6 +162,7 @@ const seedProfileTypes = (): Partial[] => [ }, { ..._TYPES_BASE, + background_color: '#baa0cc', name: 'collection-point', display_name: 'Collection Point', is_space: true, diff --git a/shared/mocks/data/profileTypes.ts b/shared/mocks/data/profileTypes.ts index 3cbe6abfcb..1e64fd7115 100644 --- a/shared/mocks/data/profileTypes.ts +++ b/shared/mocks/data/profileTypes.ts @@ -3,6 +3,7 @@ import type { DBProfileType } from '../../models/profileType'; export const profileTypes: Partial[] = [ { name: 'member', + background_color: '#f090b3', display_name: 'Member', is_space: false, description: '', @@ -15,6 +16,7 @@ export const profileTypes: Partial[] = [ }, { name: 'machine-builder', + background_color: '#f29195', display_name: 'Machine Builder', order: 3, description: '', @@ -27,6 +29,7 @@ export const profileTypes: Partial[] = [ }, { name: 'workspace', + background_color: '#97cdeb', display_name: 'Workspace', order: 2, description: '', @@ -39,6 +42,7 @@ export const profileTypes: Partial[] = [ }, { name: 'community-point', + background_color: '#8ec685', display_name: 'Community Point', order: 4, description: '', @@ -51,6 +55,7 @@ export const profileTypes: Partial[] = [ }, { name: 'collection-point', + background_color: '#baa0cc', display_name: 'Collection Point', order: 5, description: '', diff --git a/shared/models/profileType.ts b/shared/models/profileType.ts index 8f856c0403..39be1809f4 100644 --- a/shared/models/profileType.ts +++ b/shared/models/profileType.ts @@ -1,5 +1,6 @@ export class DBProfileType { id: number; + background_color: string; description: string; display_name: string; image_url: string; @@ -16,6 +17,7 @@ export class DBProfileType { export class ProfileType { id: number; + backgroundColor: string; description: string; displayName: string; imageUrl: string; @@ -32,6 +34,7 @@ export class ProfileType { static fromDB(value: DBProfileType) { return new ProfileType({ id: value.id, + backgroundColor: value.background_color, description: value.description, displayName: value.display_name, imageUrl: value.image_url, diff --git a/src/pages/Maps/Content/MapView/Cluster.client.tsx b/src/pages/Maps/Content/MapView/Cluster.client.tsx index 353ccc6584..cff887cb65 100644 --- a/src/pages/Maps/Content/MapView/Cluster.client.tsx +++ b/src/pages/Maps/Content/MapView/Cluster.client.tsx @@ -1,7 +1,7 @@ import L from 'leaflet'; import { type MarkerCluster, MarkerClusterGroup } from 'leaflet.markercluster'; import type { MapPin } from 'oa-shared'; -import { type RefObject, useEffect, useRef } from 'react'; +import { type RefObject, useEffect, useRef, useState } from 'react'; import { useMap } from 'react-leaflet'; import { createClusterIcon, createMarkerIcon } from './Sprites'; @@ -10,6 +10,7 @@ interface IProps { onPinClick: (pin: MapPin) => void; onClusterClick: (cluster: MarkerCluster) => void; clusterGroupRef?: RefObject; + selectedPin?: MapPin | null; } /** @@ -19,7 +20,12 @@ interface IProps { * * @see https://github.com/Leaflet/Leaflet.markercluster#clusters-methods */ -export const Clusters = ({ pins, onPinClick, onClusterClick, clusterGroupRef }: IProps) => { +export const Clusters = (props: IProps) => { + const { pins, onPinClick, onClusterClick, clusterGroupRef, selectedPin } = props; + const [previousSelectedPin, setPreviousSelectedPin] = useState( + selectedPin || null, + ); + const map = useMap(); const groupRef = useRef(null); const markersRef = useRef>(new Map()); @@ -69,6 +75,42 @@ export const Clusters = ({ pins, onPinClick, onClusterClick, clusterGroupRef }: }; }, [map, clusterGroupRef]); + useEffect(() => { + const setSelectedPin = (selectedPin) => { + const currentMarkers = markersRef.current; + const id = String(selectedPin?.id); + + if (currentMarkers.has(id) && selectedPin) { + console.log('hello'); + currentMarkers.get(id)?.setIcon(createMarkerIcon(selectedPin, true)); + } + }; + + const clearSelectedPin = () => { + const currentMarkers = markersRef.current; + const id = String(previousSelectedPin?.id); + + if (currentMarkers.has(id)) { + previousSelectedPin && + currentMarkers.get(id)?.setIcon(createMarkerIcon(previousSelectedPin, false)); + } + }; + + if (selectedPin && !previousSelectedPin) { + setSelectedPin(selectedPin); + setPreviousSelectedPin(selectedPin); + } + if (selectedPin && selectedPin.id !== previousSelectedPin?.id) { + clearSelectedPin(); + setSelectedPin(selectedPin); + setPreviousSelectedPin(selectedPin); + } + if (!selectedPin && previousSelectedPin) { + clearSelectedPin(); + setPreviousSelectedPin(null); + } + }, [selectedPin]); + // Differential marker updates — only add/remove changed pins. useEffect(() => { const group = groupRef.current; @@ -93,9 +135,11 @@ export const Clusters = ({ pins, onPinClick, onClusterClick, clusterGroupRef }: const toAdd: L.Marker[] = []; for (const pin of validPins) { const id = String(pin.id); + const isSelectedPin = pin.id === selectedPin?.id; + if (!currentMarkers.has(id)) { const marker = L.marker([pin.lat, pin.lng], { - icon: createMarkerIcon(pin), + icon: createMarkerIcon(pin, isSelectedPin), }); marker.on('click', () => onPinClickRef.current(pin)); currentMarkers.set(id, marker); diff --git a/src/pages/Maps/Content/MapView/MapView.client.tsx b/src/pages/Maps/Content/MapView/MapView.client.tsx index a59d523f4e..7d54cc63fb 100644 --- a/src/pages/Maps/Content/MapView/MapView.client.tsx +++ b/src/pages/Maps/Content/MapView/MapView.client.tsx @@ -136,13 +136,18 @@ export const MapView = ({ {mapState.mapPins && mapState.mapPins.length > 0 && ( <> {disableClusters || !mapState.selectPinWithClusterCheck ? ( - + ) : ( )} diff --git a/src/pages/Maps/Content/MapView/Markers.client.tsx b/src/pages/Maps/Content/MapView/Markers.client.tsx index 3cc6764678..9ac1703416 100644 --- a/src/pages/Maps/Content/MapView/Markers.client.tsx +++ b/src/pages/Maps/Content/MapView/Markers.client.tsx @@ -7,12 +7,13 @@ import { createMarkerIcon } from './Sprites'; interface IProps { pins: MapPin[]; onPinClick: (pin: MapPin) => void; + selectedPin?: MapPin | null; } /** * Renders individual map pins without clustering. */ -export const Markers = ({ pins, onPinClick }: IProps) => { +export const Markers = ({ pins, onPinClick, selectedPin }: IProps) => { const map = useMap(); const markersRef = useRef>(new Map()); const onPinClickRef = useRef(onPinClick); @@ -39,9 +40,11 @@ export const Markers = ({ pins, onPinClick }: IProps) => { // Add new markers for (const pin of validPins) { const id = String(pin.id); + const isSelectedPin = pin.id === selectedPin?.id; + if (!currentMarkers.has(id)) { const marker = L.marker([pin.lat, pin.lng], { - icon: createMarkerIcon(pin), + icon: createMarkerIcon(pin, isSelectedPin), }); marker.on('click', () => onPinClickRef.current(pin)); marker.addTo(map); diff --git a/src/pages/Maps/Content/MapView/Sprites.tsx b/src/pages/Maps/Content/MapView/Sprites.tsx index 3319f53956..73858253ec 100644 --- a/src/pages/Maps/Content/MapView/Sprites.tsx +++ b/src/pages/Maps/Content/MapView/Sprites.tsx @@ -70,18 +70,24 @@ export const createClusterIcon = () => { }; }; -export const createMarkerIcon = (pin: MapPin, draggable?: boolean) => { +export const createMarkerIcon = (pin: MapPin, isSelectedPin: boolean) => { const icon = pin.moderation === 'accepted' ? pin.profile!.type?.smallImageUrl || clusterIcon : AwaitingModerationHighlight; + let className = `icon-marker icon-${pin.profile!.type?.name}`; + const shouldShowAnimationStyle = isSelectedPin && pin.profile.type?.backgroundColor; + return divIcon({ - className: `icon-marker icon-${pin.profile!.type}`, - html: ``, + className, + html: `
+ +
+
`, iconSize: point(38, 38, true), }); }; diff --git a/src/pages/Maps/Content/MapView/sprites.css b/src/pages/Maps/Content/MapView/sprites.css index f14d6cc571..0e7c4e4c8d 100644 --- a/src/pages/Maps/Content/MapView/sprites.css +++ b/src/pages/Maps/Content/MapView/sprites.css @@ -2,14 +2,6 @@ line-height: 26px; text-align: center; color: white; - padding-left: 1px; - border-radius: 50%; -} - -/* To make sure images are not -bigger than image container */ -.icon-marker img { - width: 100%; } .icon-cluster-text { @@ -19,3 +11,17 @@ bigger than image container */ width: 100%; height: 100%; } + +.icon-marker .icon-marker-selected { + transform: translateY(-5px); +} + +.icon-marker-selected-after { + position: relative; + top: -47px; + bottom: 0; + padding-bottom: 10px; + height: 48px !important; + clip-path: polygon(85% 69%, 51% 100%, 15% 69%, 50% 20%); + z-index: -1; +} diff --git a/src/pages/UserSettings/content/sections/ProfileType.section.test.tsx b/src/pages/UserSettings/content/sections/ProfileType.section.test.tsx index 95e1a1290f..8fc4149459 100644 --- a/src/pages/UserSettings/content/sections/ProfileType.section.test.tsx +++ b/src/pages/UserSettings/content/sections/ProfileType.section.test.tsx @@ -26,6 +26,7 @@ describe('Focus', () => { profileTypes={[ { id: 1, + backgroundColor: '#f090b3', name: 'member', displayName: 'Member', description: 'Member desc', @@ -50,6 +51,7 @@ describe('Focus', () => { profileTypes={[ { id: 1, + backgroundColor: '#f090b3', name: 'member', displayName: 'Member', description: 'Member desc', @@ -61,6 +63,7 @@ describe('Focus', () => { }, { id: 2, + backgroundColor: '#97cdeb', name: 'space', displayName: 'Space', description: 'space desc', diff --git a/src/routes/api.map-pin.ts b/src/routes/api.map-pin.ts index cfc952ba9f..9ec9d76397 100644 --- a/src/routes/api.map-pin.ts +++ b/src/routes/api.map-pin.ts @@ -59,6 +59,7 @@ export const loader = async ({ request }) => { ), type:profile_types( id, + background_color, name, display_name, image_url, diff --git a/src/routes/api.map-pins.$userId.ts b/src/routes/api.map-pins.$userId.ts index 4620400aaf..9cb9702552 100644 --- a/src/routes/api.map-pins.$userId.ts +++ b/src/routes/api.map-pins.$userId.ts @@ -48,6 +48,7 @@ export const loader = async ({ request, params }) => { ), type:profile_types( id, + background_color, name, display_name, image_url, diff --git a/src/services/mapPinsService.server.ts b/src/services/mapPinsService.server.ts index 3967436688..49cb2faf42 100644 --- a/src/services/mapPinsService.server.ts +++ b/src/services/mapPinsService.server.ts @@ -63,6 +63,7 @@ export class MapPinsServiceServer { ), type:profile_types( id, + background_color, name, display_name, image_url, diff --git a/src/services/mapService.server.ts b/src/services/mapService.server.ts index 2c258608c6..8996180524 100644 --- a/src/services/mapService.server.ts +++ b/src/services/mapService.server.ts @@ -64,6 +64,7 @@ export class MapServiceServer { ), type:profile_types( id, + background_color, name, display_name, image_url, @@ -128,6 +129,7 @@ export class MapServiceServer { ), type:profile_types( id, + background_color, name, display_name, image_url, diff --git a/src/services/profileService.server.ts b/src/services/profileService.server.ts index b6d5147a47..52135a16e4 100644 --- a/src/services/profileService.server.ts +++ b/src/services/profileService.server.ts @@ -23,6 +23,7 @@ export class ProfileServiceServer { ), type:profile_types( id, + background_color, name, display_name, image_url, @@ -49,6 +50,7 @@ export class ProfileServiceServer { `*, type:profile_types( id, + background_color, name, display_name, image_url, @@ -100,6 +102,7 @@ export class ProfileServiceServer { ), type:profile_types( id, + background_color, name, display_name, image_url, @@ -141,6 +144,7 @@ export class ProfileServiceServer { ), type:profile_types( id, + background_color, name, display_name, image_url, diff --git a/src/services/profileTypesService.server.ts b/src/services/profileTypesService.server.ts index fc8a27c293..09448d67e5 100644 --- a/src/services/profileTypesService.server.ts +++ b/src/services/profileTypesService.server.ts @@ -24,6 +24,7 @@ export class ProfileTypesServiceServer { const profileTypesResult = await this.client.from('profile_types').select(` id, + background_color, name, display_name, order, diff --git a/src/test/factories/User.ts b/src/test/factories/User.ts index c040dbd47f..fee8e24cc9 100644 --- a/src/test/factories/User.ts +++ b/src/test/factories/User.ts @@ -15,6 +15,7 @@ export const FactoryUser = (userOverloads: Partial = {}): Partial = {}): D impact: null, type: { id: faker.number.int(), + background_color: '#f090b3', name: faker.word.noun(), display_name: faker.word.noun(), description: faker.word.noun(), diff --git a/supabase/migrations/20260707140410_add_background_color_to_profile_types.sql b/supabase/migrations/20260707140410_add_background_color_to_profile_types.sql new file mode 100644 index 0000000000..4b8c112a1d --- /dev/null +++ b/supabase/migrations/20260707140410_add_background_color_to_profile_types.sql @@ -0,0 +1 @@ +alter table "public"."profile_types" add column "background_color" text; diff --git a/supabase/schemas/profiles.sql b/supabase/schemas/profiles.sql index 4e0680ee6c..39c953cc38 100644 --- a/supabase/schemas/profiles.sql +++ b/supabase/schemas/profiles.sql @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS "public"."profile_tags_relations" ( CREATE TABLE IF NOT EXISTS "public"."profile_types" ( "id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "background_color" "text" NOT NULL, "name" "text" NOT NULL, "display_name" "text" NOT NULL, "order" smallint NOT NULL,