Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function App() {
// States.
const [map, setMap] = useState<Map>();
const [editingLayerId, setEditingLayerId] = useState<string>();
const [activeBasemap, setActiveBasemap] = useState<string>('satellite');

// Hooks.
const {
Expand All @@ -36,6 +37,8 @@ function App() {
layerManager,
editingLayerId,
setEditingLayerId,
activeBasemap,
setActiveBasemap,
}}
>
<Header />
Expand Down
3 changes: 3 additions & 0 deletions src/contexts/global.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ type GlobalContextType = {
layerManager?: LayerManager;
editingLayerId?: string;
setEditingLayerId?: React.Dispatch<React.SetStateAction<string | undefined>>;
activeBasemap?: string;
setActiveBasemap?: React.Dispatch<React.SetStateAction<string>>;
};

export const GlobalContext = createContext<GlobalContextType>({
isLayerLoading: true,
activeBasemap: 'satellite',
});
136 changes: 135 additions & 1 deletion src/header/header.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,80 @@
import { useState } from 'react';
import { useState, useContext } from 'react';
import classNames from 'classnames';
import { DropdownMenu, Icon, IconIdentifier } from '../components';
import { AuthPanel } from '../auth-panel';
import { GlobalContext } from '../contexts';

import { Search } from './search';

export const Header = () => {
const [showAuthModal, setShowAuthModal] = useState(false);
const { activeBasemap, setActiveBasemap } = useContext(GlobalContext);

const basemapOptions = [
{ id: 'standard', name: 'Standard', desc: 'Standard street map' },
{ id: 'satellite', name: 'Satellite', desc: 'Hybrid satellite imagery' },
{ id: 'light', name: 'Light', desc: 'Minimalist light style' },
{ id: 'dark', name: 'Dark', desc: 'Minimalist dark style' },
{ id: 'none', name: 'None', desc: 'Blank slate workspace' },
];

const renderBasemapThumbnail = (id: string, sizeClass: string) => {
switch (id) {
case 'satellite':
return (
<div
className={`relative ${sizeClass} overflow-hidden rounded border border-emerald-800 bg-emerald-950`}
>
<div className="absolute inset-0 bg-gradient-to-tr from-blue-900/60 via-emerald-800/60 to-teal-900/60" />
<div className="absolute -left-1 -top-1 size-3 rounded-full bg-blue-500/20 blur-[2px]" />
<div className="absolute -bottom-2 -right-2 size-4 rounded-full bg-emerald-500/30 blur-[2px]" />
</div>
);
case 'standard':
return (
<div
className={`relative ${sizeClass} overflow-hidden rounded border border-sky-400/30 bg-slate-100`}
>
<div className="absolute left-0 top-1/2 h-[2px] w-full -translate-y-1/2 bg-amber-400/80" />
<div className="absolute left-1/3 top-0 h-full w-[2px] bg-slate-300" />
<div className="absolute left-2/3 top-0 h-full w-[2px] bg-slate-300" />
<div className="absolute left-0 top-1/4 h-px w-full bg-slate-300" />
</div>
);
case 'light':
return (
<div
className={`relative ${sizeClass} overflow-hidden rounded border border-gray-300 bg-gray-50`}
>
<div className="absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-slate-200" />
<div className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-slate-200" />
<div className="absolute left-1/4 top-1/4 size-2 rounded-full border border-slate-200/50 bg-slate-100" />
</div>
);
case 'dark':
return (
<div
className={`relative ${sizeClass} overflow-hidden rounded border border-zinc-700 bg-zinc-900`}
>
<div className="absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-zinc-800" />
<div className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-zinc-800" />
<div className="absolute left-1/4 top-1/3 size-2 rounded bg-zinc-800" />
</div>
);
case 'none':
default:
return (
<div
className={`relative ${sizeClass} flex items-center justify-center overflow-hidden rounded border border-dashed border-gray-700 bg-gray-950`}
>
<div className="absolute inset-0 bg-[radial-gradient(#374151_1px,transparent_1px)] [background-size:6px_6px]" />
<div className="scale-90 text-[8px] font-semibold uppercase tracking-tighter text-gray-500">
None
</div>
</div>
);
}
};

return (
<header className="sticky top-0 z-50 flex h-[3.4rem] w-full items-center justify-between border-b border-gray-700 bg-gray-900 px-4">
Expand Down Expand Up @@ -44,6 +113,71 @@ export const Header = () => {

{/* Right Side */}
<div className="flex w-8/12 items-center justify-end gap-3 md:w-6/12 lg:w-4/12">
{/* Basemap Dropdown */}
<DropdownMenu
buttonBody={
<div className="flex select-none items-center gap-1.5">
{renderBasemapThumbnail(activeBasemap || 'satellite', 'size-4')}
<span className="hidden text-xs font-semibold uppercase tracking-wider text-gray-300 md:inline">
{activeBasemap}
</span>
</div>
}
className="flex h-8 items-center justify-center rounded-lg border border-transparent bg-transparent px-2 text-gray-400 transition-colors hover:border-gray-700 hover:bg-gray-800 hover:text-gray-50 focus:border-gray-700 focus:bg-gray-800 focus:text-gray-50 data-[open]:border-gray-700 data-[open]:bg-gray-800 data-[open]:text-gray-50"
anchor="bottom end"
>
<div className="mb-1 border-b border-gray-700/50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-gray-400">
Basemap
</div>
{basemapOptions.map((opt) => {
const isActive = activeBasemap === opt.id;
return (
<DropdownMenu.Item
key={opt.id}
onClick={() => setActiveBasemap?.(opt.id)}
className={classNames(
'flex w-56 items-center gap-3 px-3 py-2 transition-all duration-150',
isActive
? 'bg-blue-500/10 font-medium text-blue-400'
: 'text-gray-300 hover:text-white',
)}
>
<div
className={classNames(
'relative rounded border p-[2px] transition-all duration-150',
isActive
? 'border-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]'
: 'border-transparent',
)}
>
{renderBasemapThumbnail(opt.id, 'size-8')}
</div>
<div className="flex flex-col text-left">
<span
className={classNames(
'text-xs font-semibold',
isActive ? 'text-blue-400' : 'text-gray-200',
)}
>
{opt.name}
</span>
<span className="text-[10px] leading-tight text-gray-500">
{opt.desc}
</span>
</div>
{isActive && (
<div className="ml-auto flex size-4 items-center justify-center rounded-full bg-blue-500/20 text-blue-400">
<Icon
identifier={IconIdentifier.Tick}
className="size-2.5"
/>
</div>
)}
</DropdownMenu.Item>
);
})}
</DropdownMenu>

<DropdownMenu
iconIdentifier={IconIdentifier.Settings}
className="flex size-8 items-center justify-center rounded-lg border border-transparent bg-transparent text-gray-400 transition-colors hover:border-gray-700 hover:bg-gray-800 hover:text-gray-50 focus:border-gray-700 focus:bg-gray-800 focus:text-gray-50 data-[open]:border-gray-700 data-[open]:bg-gray-800 data-[open]:text-gray-50"
Expand Down
55 changes: 55 additions & 0 deletions src/map-container/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,70 @@ export const baseStyleSpec: StyleSpecification = {
tileSize: 256,
maxzoom: 22,
},
standard: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 19,
attribution: '&copy; OpenStreetMap contributors',
},
light: {
type: 'raster',
tiles: ['https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 20,
attribution: '&copy; CARTO',
},
dark: {
type: 'raster',
tiles: ['https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 20,
attribution: '&copy; CARTO',
},

terrainSource,
},

layers: [
{
id: 'background',
type: 'background',
paint: {
'background-color': '#111827',
},
},
{
id: 'satellite',
type: 'raster',
source: 'satellite',
layout: {
visibility: 'visible',
},
},
{
id: 'standard',
type: 'raster',
source: 'standard',
layout: {
visibility: 'none',
},
},
{
id: 'light',
type: 'raster',
source: 'light',
layout: {
visibility: 'none',
},
},
{
id: 'dark',
type: 'raster',
source: 'dark',
layout: {
visibility: 'none',
},
},
],
};
22 changes: 21 additions & 1 deletion src/map-container/map-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,34 @@ export const MapView: React.FC<HTMLProps<HTMLDivElement>> = ({
const mapContainerRef = useRef(null);

// Context.
const { setMap } = useContext(GlobalContext);
const { setMap, map, activeBasemap } = useContext(GlobalContext);
const { values: authRecords } = useAuthStore();
const authRecordsRef = useRef(authRecords);

useEffect(() => {
authRecordsRef.current = authRecords;
}, [authRecords]);

// Sync basemap layer visibilities
useEffect(() => {
if (!map) return;

const basemapLayerIds = ['satellite', 'standard', 'light', 'dark'];
basemapLayerIds.forEach((id) => {
try {
if (map.getLayer(id)) {
map.setLayoutProperty(
id,
'visibility',
activeBasemap === id ? 'visible' : 'none',
);
}
} catch (err) {
console.warn(`Failed to toggle visibility for layer ${id}:`, err);
}
});
}, [map, activeBasemap]);

// Constants.
const customClassNames = classNames(
'w-full h-screen-container bg-gray-600',
Expand Down
Loading