diff --git a/src/app/map/[id]/components/Markers.tsx b/src/app/map/[id]/components/Markers.tsx deleted file mode 100644 index 833fbe80..00000000 --- a/src/app/map/[id]/components/Markers.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { useContext, useMemo } from "react"; -import { Layer, Source } from "react-map-gl/mapbox"; - -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 { useLayers } from "../hooks/useLayers"; -import { mapColors } from "../styles"; -import { PublicFiltersContext } from "../view/[viewIdOrHost]/publish/context/PublicFiltersContext"; -import { PublicMapContext } from "../view/[viewIdOrHost]/publish/context/PublicMapContext"; -import type { MarkerFeature } from "@/types"; -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 rgbaString(hex: string, alpha: number) { -// const { r, g, b } = hexToRgb(hex); -// return `rgba(${r}, ${g}, ${b}, ${alpha})`; -// } - -export default function Markers() { - const { viewConfig } = useMapViews(); - const { mapConfig } = useMapConfig(); - const markerQueries = useMarkerQueries(); - const { getDataSourceVisibility } = useLayers(); - - const memberMarkers = useMemo( - () => - markerQueries?.data.find( - (dsm) => dsm.dataSourceId === mapConfig.membersDataSourceId, - ), - [markerQueries, mapConfig.membersDataSourceId], - ); - - const otherMarkers = useMemo( - () => - mapConfig.markerDataSourceIds.map((id) => - markerQueries?.data.find((dsm) => dsm.dataSourceId === id), - ), - [markerQueries, mapConfig.markerDataSourceIds], - ); - - return ( - <> - {memberMarkers && getDataSourceVisibility(memberMarkers.dataSourceId) && ( - - )} - {otherMarkers.map((markers) => { - if ( - !markers || - !viewConfig.showLocations || - !getDataSourceVisibility(markers.dataSourceId) - ) { - return null; - } - return ( - - ); - })} - - ); -} - -function DataSourceMarkers({ - dataSourceMarkers, - isMembers, -}: { - dataSourceMarkers: { dataSourceId: string; markers: MarkerFeature[] }; - isMembers: boolean; -}) { - const { filteredRecords, publicFilters } = useContext(PublicFiltersContext); - const { publicMap, colorScheme } = useContext(PublicMapContext); - - const safeMarkers = useMemo(() => { - // Don't add MARKER_CLIENT_EXCLUDED_KEY property if no public filters exist - if (Object.keys(publicFilters).length === 0) { - return { - type: "FeatureCollection", - features: dataSourceMarkers.markers, - }; - } - - // Add MARKER_CLIENT_EXCLUDED_KEY if public filters are set and marker is not matched - const recordIds = (filteredRecords || []).map((r) => r.id).filter(Boolean); - return { - type: "FeatureCollection", - features: dataSourceMarkers.markers.map((f) => ({ - ...f, - properties: { - ...f.properties, - [MARKER_CLIENT_EXCLUDED_KEY]: !recordIds.includes(f.properties.id), - }, - })), - }; - }, [dataSourceMarkers.markers, filteredRecords, publicFilters]); - - const NOT_MATCHED_CASE = [ - "any", - ["!", ["get", "matched"]], - ["==", ["get", MARKER_CLIENT_EXCLUDED_KEY], true], - ]; - - const sourceId = `${dataSourceMarkers.dataSourceId}-markers`; - const publicMapColor = - publicMap?.id && colorScheme - ? publicMapColorSchemes[colorScheme]?.primary - : ""; - const color = publicMapColor - ? publicMapColor - : isMembers - ? mapColors.member.color - : mapColors.dataSource.color; - - return ( - - - - {/* TODO: Restore this with a switch - */} - - ", ["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/Markers/ClustersLayer.tsx b/src/app/map/[id]/components/Markers/ClustersLayer.tsx new file mode 100644 index 00000000..9e6460cf --- /dev/null +++ b/src/app/map/[id]/components/Markers/ClustersLayer.tsx @@ -0,0 +1,107 @@ +import { Layer } from "react-map-gl/mapbox"; + +const NOT_MATCHED_CASE = [ + "any", + ["!", ["get", "matched"]], + ["==", ["get", "__clientExcluded"], true], +]; + +export function ClustersLayer({ + sourceId, + color, +}: { + sourceId: string; + color: string; +}) { + return ( + <> + + + + ", ["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/Markers/DataSourceMarkers.tsx b/src/app/map/[id]/components/Markers/DataSourceMarkers.tsx new file mode 100644 index 00000000..ccc9544c --- /dev/null +++ b/src/app/map/[id]/components/Markers/DataSourceMarkers.tsx @@ -0,0 +1,117 @@ +import { useContext, useMemo } from "react"; +import { Source } from "react-map-gl/mapbox"; + +import { publicMapColorSchemes } from "@/app/map/[id]/styles"; +import { MarkerDisplayMode } from "@/server/models/Map"; +import { mapColors } from "../../styles"; +import { PublicFiltersContext } from "../../view/[viewIdOrHost]/publish/context/PublicFiltersContext"; +import { PublicMapContext } from "../../view/[viewIdOrHost]/publish/context/PublicMapContext"; +import { ClustersLayer } from "./ClustersLayer"; +import { HeatmapLayer } from "./HeatmapLayer"; +import { MARKER_CLIENT_EXCLUDED_KEY } from "./utils"; +import type { MarkerFeature } from "@/types"; +import type { FeatureCollection } from "geojson"; + +export function DataSourceMarkers({ + dataSourceMarkers, + isMembers, + mapConfig, +}: { + dataSourceMarkers: { dataSourceId: string; markers: MarkerFeature[] }; + isMembers: boolean; + mapConfig: { + markerDisplayModes?: Record; + markerColors?: Record; + }; +}) { + const { filteredRecords, publicFilters } = useContext( + PublicFiltersContext, + ) || { + filteredRecords: [], + publicFilters: {}, + }; + const { publicMap, colorScheme } = useContext(PublicMapContext) || { + publicMap: null, + colorScheme: null, + }; + + const displayMode = + mapConfig.markerDisplayModes?.[dataSourceMarkers.dataSourceId] ?? + MarkerDisplayMode.Clusters; + + const safeMarkers = useMemo(() => { + if (Object.keys(publicFilters).length === 0) { + return { + type: "FeatureCollection", + features: dataSourceMarkers.markers, + }; + } + + const recordIds = (filteredRecords || []) + .map((r: { id: string | number }) => r.id) + .filter(Boolean); + return { + type: "FeatureCollection", + features: dataSourceMarkers.markers.map((f) => ({ + ...f, + properties: { + ...f.properties, + [MARKER_CLIENT_EXCLUDED_KEY]: !recordIds.includes(f.properties.id), + }, + })), + }; + }, [dataSourceMarkers.markers, filteredRecords, publicFilters]); + + const sourceId = `${dataSourceMarkers.dataSourceId}-markers`; + const publicMapColor = + publicMap?.id && colorScheme + ? publicMapColorSchemes[colorScheme]?.primary + : ""; + + const customColor = mapConfig.markerColors?.[dataSourceMarkers.dataSourceId]; + const defaultColor = isMembers + ? mapColors.member.color + : mapColors.dataSource.color; + + const color = publicMapColor || customColor || defaultColor; + + const NOT_MATCHED_CASE = [ + "any", + ["!", ["get", "matched"]], + ["==", ["get", MARKER_CLIENT_EXCLUDED_KEY], true], + ]; + + return ( + + {displayMode === MarkerDisplayMode.Clusters && ( + + )} + {displayMode === MarkerDisplayMode.Heatmap && ( + + )} + + ); +} diff --git a/src/app/map/[id]/components/Markers/HeatmapLayer.tsx b/src/app/map/[id]/components/Markers/HeatmapLayer.tsx new file mode 100644 index 00000000..0e95e8b6 --- /dev/null +++ b/src/app/map/[id]/components/Markers/HeatmapLayer.tsx @@ -0,0 +1,113 @@ +import { Layer } from "react-map-gl/mapbox"; +import { rgbaString } from "./utils"; + +const NOT_MATCHED_CASE = [ + "any", + ["!", ["get", "matched"]], + ["==", ["get", "__clientExcluded"], true], +]; + +export function HeatmapLayer({ + sourceId, + color, +}: { + sourceId: string; + color: string; +}) { + return ( + <> + + + ", ["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/Markers/index.tsx b/src/app/map/[id]/components/Markers/index.tsx new file mode 100644 index 00000000..1528ec2f --- /dev/null +++ b/src/app/map/[id]/components/Markers/index.tsx @@ -0,0 +1,60 @@ +import { useMemo } from "react"; + +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 { useLayers } from "../../hooks/useLayers"; +import { DataSourceMarkers } from "./DataSourceMarkers"; + +export default function Markers() { + const { viewConfig } = useMapViews(); + const { mapConfig } = useMapConfig(); + const markerQueries = useMarkerQueries(); + const { getDataSourceVisibility } = useLayers(); + + const memberMarkers = useMemo( + () => + markerQueries?.data.find( + (dsm) => dsm.dataSourceId === mapConfig.membersDataSourceId, + ), + [markerQueries, mapConfig.membersDataSourceId], + ); + + const otherMarkers = useMemo( + () => + mapConfig.markerDataSourceIds.map((id) => + markerQueries?.data.find((dsm) => dsm.dataSourceId === id), + ), + [markerQueries, mapConfig.markerDataSourceIds], + ); + + return ( + <> + {memberMarkers && getDataSourceVisibility(memberMarkers.dataSourceId) && ( + + )} + {otherMarkers.map((markers) => { + if ( + !markers || + !viewConfig.showLocations || + !getDataSourceVisibility(markers.dataSourceId) + ) { + return null; + } + return ( + + ); + })} + + ); +} diff --git a/src/app/map/[id]/components/Markers/utils.ts b/src/app/map/[id]/components/Markers/utils.ts new file mode 100644 index 00000000..de296ff8 --- /dev/null +++ b/src/app/map/[id]/components/Markers/utils.ts @@ -0,0 +1,45 @@ +export const MARKER_CLIENT_EXCLUDED_KEY = "__clientExcluded"; + +const HEX_COLOR_REGEX = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/; +const DEFAULT_FALLBACK_COLOR = "#808080"; // Gray as fallback + +// Pre-compute fallback color RGB values to avoid redundant calculations +const FALLBACK_RGB = (() => { + const normalized = DEFAULT_FALLBACK_COLOR.replace("#", ""); + const bigint = parseInt(normalized, 16); + return { + r: (bigint >> 16) & 255, + g: (bigint >> 8) & 255, + b: bigint & 255, + }; +})(); + +export function hexToRgb(hex: string) { + // Validate hex format + if (!HEX_COLOR_REGEX.test(hex)) { + console.warn(`Invalid hex color format: "${hex}". Using fallback color.`); + return FALLBACK_RGB; + } + + const normalized = hex.replace("#", ""); + + // Expand shorthand hex (e.g., #fff -> #ffffff) + const expanded = + normalized.length === 3 + ? normalized + .split("") + .map((char) => char + char) + .join("") + : normalized; + + const bigint = parseInt(expanded, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return { r, g, b }; +} + +export function rgbaString(hex: string, alpha: number) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} diff --git a/src/app/map/[id]/components/PlacedMarkers.tsx b/src/app/map/[id]/components/PlacedMarkers.tsx index faa95c04..7413f8ec 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,10 @@ export default function PlacedMarkers() { properties: { id: marker.id, name: marker.label, + color: + (marker.folderId && mapConfig.folderColors?.[marker.folderId]) ?? + mapConfig.placedMarkerColors?.[marker.id] ?? + mapColors.markers.color, }, geometry: { type: "Point", @@ -61,7 +67,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 +89,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 +115,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..6efe174e 100644 --- a/src/server/models/Map.ts +++ b/src/server/models/Map.ts @@ -1,9 +1,20 @@ import z from "zod"; import type { ColumnType, Generated, Insertable, Updateable } from "kysely"; +export enum MarkerDisplayMode { + Clusters = "clusters", + Heatmap = "heatmap", +} + +const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/); + export const mapConfigSchema = z.object({ markerDataSourceIds: z.array(z.string()), membersDataSourceId: z.string().nullish(), + markerDisplayModes: z.record(z.nativeEnum(MarkerDisplayMode)).optional(), + markerColors: z.record(hexColorSchema).optional(), + placedMarkerColors: z.record(hexColorSchema).optional(), + folderColors: z.record(hexColorSchema).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 });