diff --git a/src/app/map/[id]/components/Markers.tsx b/src/app/map/[id]/components/Markers.tsx index 833fbe80..07eaa360 100644 --- a/src/app/map/[id]/components/Markers.tsx +++ b/src/app/map/[id]/components/Markers.tsx @@ -5,6 +5,7 @@ import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useMarkerQueries } from "@/app/map/[id]/hooks/useMarkerQueries"; import { publicMapColorSchemes } from "@/app/map/[id]/styles"; +import { MarkerDisplayMode } from "@/server/models/Map"; import { useLayers } from "../hooks/useLayers"; import { mapColors } from "../styles"; import { PublicFiltersContext } from "../view/[viewIdOrHost]/publish/context/PublicFiltersContext"; @@ -14,19 +15,19 @@ import type { FeatureCollection } from "geojson"; const MARKER_CLIENT_EXCLUDED_KEY = "__clientExcluded"; -// function hexToRgb(hex: string) { -// const normalized = hex.replace("#", ""); -// const bigint = parseInt(normalized, 16); -// const r = (bigint >> 16) & 255; -// const g = (bigint >> 8) & 255; -// const b = bigint & 255; -// return { r, g, b }; -// } +function hexToRgb(hex: string) { + const normalized = hex.replace("#", ""); + const bigint = parseInt(normalized, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return { r, g, b }; +} -// function rgbaString(hex: string, alpha: number) { -// const { r, g, b } = hexToRgb(hex); -// return `rgba(${r}, ${g}, ${b}, ${alpha})`; -// } +function rgbaString(hex: string, alpha: number) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} export default function Markers() { const { viewConfig } = useMapViews(); @@ -57,6 +58,7 @@ export default function Markers() { key={memberMarkers.dataSourceId} dataSourceMarkers={memberMarkers} isMembers + mapConfig={mapConfig} /> )} {otherMarkers.map((markers) => { @@ -72,6 +74,7 @@ export default function Markers() { key={markers.dataSourceId} dataSourceMarkers={markers} isMembers={false} + mapConfig={mapConfig} /> ); })} @@ -82,13 +85,23 @@ export default function Markers() { function DataSourceMarkers({ dataSourceMarkers, isMembers, + mapConfig, }: { dataSourceMarkers: { dataSourceId: string; markers: MarkerFeature[] }; isMembers: boolean; + mapConfig: { + markerDisplayModes?: Record; + markerColors?: Record; + }; }) { const { filteredRecords, publicFilters } = useContext(PublicFiltersContext); const { publicMap, colorScheme } = useContext(PublicMapContext); + // Get display mode for this data source (defaults to Clusters) + const displayMode = + mapConfig.markerDisplayModes?.[dataSourceMarkers.dataSourceId] ?? + MarkerDisplayMode.Clusters; + const safeMarkers = useMemo(() => { // Don't add MARKER_CLIENT_EXCLUDED_KEY property if no public filters exist if (Object.keys(publicFilters).length === 0) { @@ -123,11 +136,14 @@ function DataSourceMarkers({ publicMap?.id && colorScheme ? publicMapColorSchemes[colorScheme]?.primary : ""; - const color = publicMapColor - ? publicMapColor - : isMembers - ? mapColors.member.color - : mapColors.dataSource.color; + + // Get custom color from mapConfig, or fall back to defaults + const customColor = mapConfig.markerColors?.[dataSourceMarkers.dataSourceId]; + const defaultColor = isMembers + ? mapColors.member.color + : mapColors.dataSource.color; + + const color = publicMapColor || customColor || defaultColor; return ( - - - {/* TODO: Restore this with a switch + {displayMode === MarkerDisplayMode.Clusters && ( + )} + {displayMode === MarkerDisplayMode.Clusters && ( + + )} + {displayMode === MarkerDisplayMode.Heatmap && ( + */} - - ", ["length", ["get", "name"]], 20], "...", ""], - ], - "text-font": ["DIN Pro Medium", "Arial Unicode MS Bold"], - "text-size": 12, - "text-transform": "uppercase", - "text-offset": [0, -1.25], - }} - paint={{ - "text-color": color, - "text-halo-color": "#ffffff", - "text-halo-width": 1, - }} - /> + /> + )} + {/* Individual pins - only show for cluster mode or at high zoom in heatmap mode */} + {displayMode === MarkerDisplayMode.Clusters && ( + + )} + {displayMode === MarkerDisplayMode.Clusters && ( + ", ["length", ["get", "name"]], 20], "...", ""], + ], + "text-font": ["DIN Pro Medium", "Arial Unicode MS Bold"], + "text-size": 12, + "text-transform": "uppercase", + "text-offset": [0, -1.25], + }} + paint={{ + "text-color": color, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }} + /> + )} + {displayMode === MarkerDisplayMode.Heatmap && ( + + )} + {displayMode === MarkerDisplayMode.Heatmap && ( + ", ["length", ["get", "name"]], 20], "...", ""], + ], + "text-font": ["DIN Pro Medium", "Arial Unicode MS Bold"], + "text-size": 12, + "text-transform": "uppercase", + "text-offset": [0, -1.25], + }} + paint={{ + "text-color": color, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }} + /> + )} ); } diff --git a/src/app/map/[id]/components/PlacedMarkers.tsx b/src/app/map/[id]/components/PlacedMarkers.tsx index faa95c04..5438ecea 100644 --- a/src/app/map/[id]/components/PlacedMarkers.tsx +++ b/src/app/map/[id]/components/PlacedMarkers.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Layer, Source } from "react-map-gl/mapbox"; import { useFoldersQuery } from "@/app/map/[id]/hooks/useFolders"; +import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { usePlacedMarkerState, @@ -12,6 +13,7 @@ import type { FeatureCollection, Point } from "geojson"; export default function PlacedMarkers() { const { viewConfig } = useMapViews(); + const { mapConfig } = useMapConfig(); const { data: folders = [] } = useFoldersQuery(); const { data: placedMarkers = [] } = usePlacedMarkersQuery(); const { selectedPlacedMarkerId, getPlacedMarkerVisibility } = @@ -40,6 +42,8 @@ export default function PlacedMarkers() { properties: { id: marker.id, name: marker.label, + color: + mapConfig.placedMarkerColors?.[marker.id] ?? mapColors.markers.color, }, geometry: { type: "Point", @@ -61,7 +65,7 @@ export default function PlacedMarkers() { source="search-history" paint={{ "circle-radius": ["interpolate", ["linear"], ["zoom"], 8, 3, 16, 8], - "circle-color": mapColors.markers.color, + "circle-color": ["get", "color"], "circle-opacity": 0.8, "circle-stroke-width": 1, "circle-stroke-color": "#ffffff", @@ -83,7 +87,7 @@ export default function PlacedMarkers() { "text-anchor": "top", }} paint={{ - "text-color": mapColors.markers.color, + "text-color": ["get", "color"], "text-halo-color": "#ffffff", "text-halo-width": 1, }} @@ -109,7 +113,7 @@ export default function PlacedMarkers() { "circle-color": "#ffffff", "circle-opacity": 0, "circle-stroke-width": 2, - "circle-stroke-color": mapColors.markers.color, + "circle-stroke-color": ["get", "color"], }} /> )} diff --git a/src/app/map/[id]/components/controls/ControlWrapper.tsx b/src/app/map/[id]/components/controls/ControlWrapper.tsx index dc6979d6..3f121690 100644 --- a/src/app/map/[id]/components/controls/ControlWrapper.tsx +++ b/src/app/map/[id]/components/controls/ControlWrapper.tsx @@ -10,14 +10,20 @@ export default function ControlWrapper({ name, isVisible, onVisibilityToggle, + color, }: { children: ReactNode; name: string; isVisible: boolean; onVisibilityToggle: () => void; layerType?: LayerType; + color?: string; }) { const getLayerColor = () => { + // Use custom color if provided, otherwise use default layer color + if (color) { + return color; + } switch (layerType) { case LayerType.Member: return mapColors.member.color; diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index 01f5c021..19c2f6aa 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -4,8 +4,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import ColorPalette from "@/components/ColorPalette"; import ContextMenuContentWithFocus from "@/components/ContextMenuContentWithFocus"; import DataSourceIcon from "@/components/DataSourceIcon"; +import { MarkerDisplayMode } from "@/server/models/Map"; import { useTRPC } from "@/services/trpc/react"; import { AlertDialog, @@ -19,8 +21,13 @@ import { } from "@/shadcn/ui/alert-dialog"; import { ContextMenu, + ContextMenuCheckboxItem, ContextMenuItem, + ContextMenuLabel, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@/shadcn/ui/context-menu"; import { LayerType } from "@/types"; @@ -64,6 +71,31 @@ export default function DataSourceItem({ const isVisible = getDataSourceVisibility(dataSource?.id); + // Get current display mode (defaults to Clusters) + const currentDisplayMode = + mapConfig.markerDisplayModes?.[dataSource.id] ?? MarkerDisplayMode.Clusters; + + // Get current color (defaults to layer color) + const currentColor = mapConfig.markerColors?.[dataSource.id] ?? layerColor; + + const handleDisplayModeChange = (mode: MarkerDisplayMode) => { + updateMapConfig({ + markerDisplayModes: { + ...mapConfig.markerDisplayModes, + [dataSource.id]: mode, + }, + }); + }; + + const handleColorChange = (color: string) => { + updateMapConfig({ + markerColors: { + ...mapConfig.markerColors, + [dataSource.id]: color, + }, + }); + }; + // Focus management for rename input useEffect(() => { if (isRenaming) { @@ -138,12 +170,13 @@ export default function DataSourceItem({ onVisibilityToggle={() => setDataSourceVisibility(dataSource?.id, !isVisible) } + color={currentColor} > + ))} + + ); +} diff --git a/src/server/models/Map.ts b/src/server/models/Map.ts index 713eedc6..a5031c4a 100644 --- a/src/server/models/Map.ts +++ b/src/server/models/Map.ts @@ -1,9 +1,18 @@ import z from "zod"; import type { ColumnType, Generated, Insertable, Updateable } from "kysely"; +export enum MarkerDisplayMode { + Clusters = "clusters", + Heatmap = "heatmap", +} + export const mapConfigSchema = z.object({ markerDataSourceIds: z.array(z.string()), membersDataSourceId: z.string().nullish(), + markerDisplayModes: z.record(z.nativeEnum(MarkerDisplayMode)).optional(), + markerColors: z.record(z.string()).optional(), + placedMarkerColors: z.record(z.string()).optional(), + folderColors: z.record(z.string()).optional(), }); export type MapConfig = z.infer; diff --git a/src/server/trpc/routers/map.ts b/src/server/trpc/routers/map.ts index 1f1480a6..cef5e10b 100644 --- a/src/server/trpc/routers/map.ts +++ b/src/server/trpc/routers/map.ts @@ -125,6 +125,10 @@ export const mapRouter = router({ const config = { markerDataSourceIds: mapConfig.markerDataSourceIds?.filter(Boolean), membersDataSourceId: mapConfig.membersDataSourceId, + markerDisplayModes: mapConfig.markerDisplayModes, + markerColors: mapConfig.markerColors, + placedMarkerColors: mapConfig.placedMarkerColors, + folderColors: mapConfig.folderColors, } as z.infer; return updateMap(mapId, { config });