Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app/(private)/map/[id]/atoms/mapStateAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const mapModeAtom = atom<MapMode>("private");
*/
export const isPublicMapRouteAtom = atom<boolean>(false);

/**
* `true` on the read-only shared map page (`/share/[token]`), `false`
* everywhere else. Components use `useMapEditable()` to hide editing
* affordances, and `useMapViews` skips server writes so view-config
* changes (map style, timeline) stay client-side only.
*/
export const isReadOnlyRouteAtom = atom<boolean>(false);

/** Derived: the navbar is visible exactly when we are NOT on the public route. */
export const showNavbarAtom = atom<boolean>(
(get) => !get(isPublicMapRouteAtom),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useMapEditable } from "../../hooks/useMapEditable";
import { useOpenInspectorConfig } from "../../hooks/useOpenInspectorConfig";
import DataRecordsPanel from "./DataRecordsPanel";
import { InspectorConfigModal } from "./InspectorConfigModal";
Expand All @@ -20,6 +21,7 @@ export default function ConfigurableDataRecordsPanel({
}) {
const { config, isModalOpen, setIsModalOpen, openConfig, onUpdateConfig } =
useOpenInspectorConfig(dataSourceId);
const editable = useMapEditable();

return (
<>
Expand All @@ -29,7 +31,7 @@ export default function ConfigurableDataRecordsPanel({
isLoading={isLoading}
defaultExpanded={defaultExpanded}
hint={hint}
onClickConfigure={openConfig}
onClickConfigure={editable ? openConfig : undefined}
/>
{config && (
<InspectorConfigModal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { LayersIcon, Settings2Icon } from "lucide-react";
import { useState } from "react";
import { useInspectorContent } from "@/app/(private)/map/[id]/hooks/useInspector";
import { useInspectorState } from "@/app/(private)/map/[id]/hooks/useInspectorState";
import { useMapEditable } from "@/app/(private)/map/[id]/hooks/useMapEditable";
import { useOpenInspectorConfig } from "@/app/(private)/map/[id]/hooks/useOpenInspectorConfig";
import { useViewInspectorConfig } from "@/app/(private)/map/[id]/hooks/useViewInspectorConfig";
import DataSourceIcon from "@/components/DataSourceIcon";
Expand Down Expand Up @@ -88,6 +89,7 @@ export default function InspectorDataTab() {
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const { config, isModalOpen, setIsModalOpen, openConfig, onUpdateConfig } =
useOpenInspectorConfig(dataSource?.id);
const editable = useMapEditable();

const { data: recordData, isFetching: recordLoading } = useQuery(
trpc.dataRecord.byId.queryOptions(
Expand Down Expand Up @@ -238,15 +240,17 @@ export default function InspectorDataTab() {
<p className="truncate">{dataSource.name}</p>
</div>
</div>
<button
type="button"
className="cursor-pointer shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Configure inspector"
title="Configure inspector"
onClick={openConfig}
>
<Settings2Icon className="h-4 w-4" />
</button>
{editable && (
<button
type="button"
className="cursor-pointer shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Configure inspector"
title="Configure inspector"
onClick={openConfig}
>
<Settings2Icon className="h-4 w-4" />
</button>
)}
</div>
)}

Expand Down Expand Up @@ -311,15 +315,17 @@ export default function InspectorDataTab() {
defaultExpanded={index === 0}
/>
))}
<Button
variant="outline"
className="w-full"
size="sm"
onClick={() => setConfigDialogOpen(true)}
>
<Settings2Icon />
Manage inspector data
</Button>
{editable && (
<Button
variant="outline"
className="w-full"
size="sm"
onClick={() => setConfigDialogOpen(true)}
>
<Settings2Icon />
Manage inspector data
</Button>
)}
<Dialog open={configDialogOpen} onOpenChange={setConfigDialogOpen}>
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
<DialogHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useDisplayAreaStat } from "@/app/(private)/map/[id]/hooks/useDisplayAre
import { useInspectorContent } from "@/app/(private)/map/[id]/hooks/useInspector";
import { useInspectorState } from "@/app/(private)/map/[id]/hooks/useInspectorState";
import { useMapRef } from "@/app/(private)/map/[id]/hooks/useMapCore";
import { useMapEditable } from "@/app/(private)/map/[id]/hooks/useMapEditable";
import { useSelectedAreas } from "@/app/(private)/map/[id]/hooks/useSelectedAreas";
import { useSelectedSecondaryArea } from "@/app/(private)/map/[id]/hooks/useSelectedSecondaryArea";
import { useTable } from "@/app/(private)/map/[id]/hooks/useTable";
Expand Down Expand Up @@ -55,6 +56,7 @@ export default function InspectorPanel() {
const [selectedSecondaryArea] = useSelectedSecondaryArea();
const [selectedAreas, setSelectedAreas] = useSelectedAreas();
const organisationId = useOrganisationId();
const editable = useMapEditable();

// Selecting something new re-opens a minimised inspector
const contentKey = `${type ?? ""}:${String(inspectorContent?.name ?? "")}`;
Expand Down Expand Up @@ -365,14 +367,16 @@ export default function InspectorPanel() {
</UnderlineTabs>
{type === LayerType.Boundary && (
<div className="border-t p-3 flex flex-col gap-2">
<Button
className="w-full"
onClick={handleAddToMyAreas}
disabled={savingTurf || !areaGeoJson}
>
<PlusIcon />
Add to areas
</Button>
{editable && (
<Button
className="w-full"
onClick={handleAddToMyAreas}
disabled={savingTurf || !areaGeoJson}
>
<PlusIcon />
Add to areas
</Button>
)}
<Button
variant="secondary"
className="w-full"
Expand All @@ -386,15 +390,16 @@ export default function InspectorPanel() {
)}
{hasData &&
type !== LayerType.Boundary &&
((isDetailsView && focusedRecord?.geocodePoint) || dataSource) && (
((isDetailsView && focusedRecord?.geocodePoint) ||
(dataSource && editable)) && (
<div className="border-t p-3 flex flex-col gap-2">
{isDetailsView && focusedRecord?.geocodePoint && (
<Button onClick={handleFlyToMarker}>
<MapPinIcon />
View on map
</Button>
)}
{dataSource && (
{dataSource && editable && (
<Button
variant="secondary"
onClick={() => setSelectedDataSourceId(dataSource.id)}
Expand Down
77 changes: 77 additions & 0 deletions src/app/(private)/map/[id]/components/Legend/LegendDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

import { LoaderPinwheel } from "lucide-react";
import { useMapViews } from "@/app/(private)/map/[id]/hooks/useMapViews";
import { DUMMY_COUNT_COLUMN } from "@/constants";
import { useChoroplethDataSource } from "@/hooks/useDataSources";
import { AreaSetGroupCodeLabels } from "@/labels";
import { useColorScheme } from "../../colors";
import { useAreaStats } from "../../data";
import BivariateLegend from "../BivariateLagend";
import { LegendBars } from "./LegendBars";

/**
* Display-only legend for the read-only shared map page: the choropleth
* colour bars and their labels, with none of the editor `Legend`'s data
* source / column / boundary configuration controls.
*/
export default function LegendDisplay() {
const { viewConfig } = useMapViews();
const dataSource = useChoroplethDataSource();

const areaStatsQuery = useAreaStats();
const areaStats = areaStatsQuery?.data;
const isLoading = areaStatsQuery?.isFetching;

const colorScheme = useColorScheme({
areaStats,
viewConfig,
});

const hasColumn = Boolean(viewConfig.areaDataColumn);
if (!viewConfig.areaDataSourceId || !hasColumn) {
return null;
}

const isCount = viewConfig.areaDataColumn === DUMMY_COUNT_COLUMN;
const isBivariate = Boolean(
!isCount && viewConfig.areaDataColumn && viewConfig.areaDataSecondaryColumn,
);

const columnLabel = isCount
? "Count of records"
: viewConfig.areaDataColumn || "";
const boundaryLabel = viewConfig.areaSetGroupCode
? AreaSetGroupCodeLabels[viewConfig.areaSetGroupCode]
: null;

return (
<div className="flex flex-col rounded-sm bg-white border border-neutral-200 w-full shadow-sm">
<div className="flex flex-col gap-0.5 p-2 pb-1">
<p className="text-xs font-semibold truncate">{columnLabel}</p>
<p className="text-[11px] text-muted-foreground truncate">
{dataSource?.name}
{boundaryLabel ? ` · ${boundaryLabel}` : ""}
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center px-3 py-4">
<LoaderPinwheel className="w-5 h-5 animate-spin text-neutral-400" />
</div>
) : isBivariate ? (
<div className="px-2 pb-2">
<BivariateLegend />
</div>
) : colorScheme ? (
<div className="flex px-2 pb-2">
<LegendBars
colorScheme={colorScheme}
viewConfig={viewConfig}
areaStats={areaStats}
dataSource={dataSource}
/>
</div>
) : null}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import { useEffect } from "react";
import { MapType } from "@/models/MapView";
import { useMapViews } from "../../hooks/useMapViews";
import BoundaryHoverInfo from "../BoundaryHoverInfo/BoundaryHoverInfo";
import InspectorPanel from "../InspectorPanel/InspectorPanel";
import LegendDisplay from "../Legend/LegendDisplay";
import MarkerLegend from "../Legend/MarkerLegend";
import MapStyleSelector from "../MapStyleSelector";
import TimelineControl from "../TimelineControl";
import ZoomControl from "../ZoomControl";

/**
* Map furniture for the read-only shared map page: the boundary hover
* info, the inspector, display-only legends, and the map style / zoom /
* timeline controls. No control panels, no draw or pin-drop modes.
*/
export default function ReadOnlyMapControls() {
const { viewConfig } = useMapViews();

// Lets global.css place the report-a-bug trigger in a row with the map's
// zoom controls (it defaults to the corner the zoom controls occupy)
useEffect(() => {
document.body.classList.add("map-page");
return () => document.body.classList.remove("map-page");
}, []);

return (
<>
<div className="absolute top-5 left-4 right-4 bottom-[100px] z-10 pointer-events-none flex justify-between items-start gap-4">
<BoundaryHoverInfo />
<InspectorPanel />
</div>

<div className="absolute bottom-8 left-8 z-10 hidden md:flex flex-col gap-2 w-64 pointer-events-auto">
<MarkerLegend />
<LegendDisplay />
<MapStyleSelector />
</div>

<div className="map-zoom-controls / absolute bottom-8 right-8 z-10 hidden md:block">
<ZoomControl />
</div>

{viewConfig.mapType !== MapType.Hex && (
<div
className="absolute left-1/2 z-10"
style={{ transform: "translate(-50%)", bottom: "32px" }}
>
<TimelineControl />
</div>
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import { cn } from "@/shadcn/utils";
import { useMapViews, useViewIdAtom } from "../../hooks/useMapViews";

/**
* View switcher for the read-only shared map page: lists the map's views
* and switches between them. No create / rename / reorder / delete.
*/
export default function ReadOnlyMapViews() {
const { views } = useMapViews();
const [viewId, setViewId] = useViewIdAtom();

if (views.length < 2) {
return null;
}

const sortedViews = [...views].sort((a, b) => a.position - b.position);

const onSelectView = (id: string) => {
setViewId(id);
// Keep the URL shareable without a navigation
const url = new URL(window.location.href);
url.searchParams.set("viewId", id);
window.history.replaceState(null, "", url.toString());
};

return (
<div className="flex items-center gap-1 overflow-x-auto">
{sortedViews.map((view) => (
<button
key={view.id}
type="button"
className={cn(
"px-3 py-1 rounded text-sm whitespace-nowrap cursor-pointer transition-colors",
view.id === viewId
? "bg-neutral-100 font-medium"
: "text-neutral-500 hover:text-neutral-800 hover:bg-neutral-50",
)}
onClick={() => onSelectView(view.id)}
>
{view.name}
</button>
))}
</div>
);
}
42 changes: 42 additions & 0 deletions src/app/(private)/map/[id]/components/readonly/ReadOnlyNavbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import dynamic from "next/dynamic";
import Navbar from "@/components/layout/Navbar";
import { useMapId } from "../../hooks/useMapCore";
import { useMapQuery } from "../../hooks/useMapQuery";
import ReadOnlyMapViews from "./ReadOnlyMapViews";

/**
* Navbar for the read-only shared map page: map name, view switcher and
* area search. None of the private navbar's editing affordances — and
* crucially none of its side effects (initial-view creation, thumbnail
* upload), which write to the map.
*/
export default function ReadOnlyNavbar() {
const mapId = useMapId();
const { data: map } = useMapQuery(mapId);

return (
<div className="pointer-events-auto">
<Navbar>
<div className="flex justify-between items-center gap-4 w-full">
<div className="flex items-center gap-4 min-w-0">
<p className="truncate max-w-[300px] text-sm text-neutral-600">
{map ? map.name : "Loading..."}
</p>
<ReadOnlyMapViews />
</div>
<SearchBox />
</div>
</Navbar>
</div>
);
}

const SearchBox = dynamic(
() => import("../SearchBox").then((mod) => ({ default: mod.SearchBox })),
{
ssr: false,
loading: () => null,
},
);
Loading
Loading