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
1 change: 1 addition & 0 deletions design_system_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ the two in step.
| Message | §4.9 | not needed — the app's `ObpErrorDisplay` already handles OBP errors (it detects missing-entitlement messages and renders `MissingRoleAlert`), which DS Message would not |
| KPI | §4.11 | **ported** — `src/lib/components/Kpi.svelte` |
| Table | §4.14 | **ported** — `src/lib/components/Table.svelte`. Upstream is `@tanstack/react-table`; sorting and the empty state are reimplemented in Svelte, all visual decisions transcribed |
| Toggle (group) | §4.21 | **ported** — `src/lib/components/ToggleGroup.svelte`, on `bits-ui`. Single-select group only (`value` is a string); standalone Toggle not yet ported |
| Select / Combobox | §4.17 / §4.18 | not yet ported — build on `bits-ui` |
| Tabs | §4.23 | not yet ported — build on `bits-ui` |
| Breadcrumb | §4.26 | not needed — see note below |
Expand Down
188 changes: 188 additions & 0 deletions src/lib/components/RegistryActivitiesMap.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<script lang="ts">
/**
* Every registry activity's area on one map, for the Activities page's map view.
*
* Areas are outlined by verification status, the same split the list's status pill
* makes. Small parcels vanish at country zoom, so while zoomed out each area also
* gets a dot at its centre; the dots go once the outlines are big enough to click.
* Clicking either opens a popup that links to the activity.
*/
import { onMount } from 'svelte';
import 'leaflet/dist/leaflet.css';
import { registryActivityHref, type RegistryActivity } from '$lib/registry/activities';

let {
activities,
areas,
class: className = ''
}: {
/** The activities to show — already filtered by the page. */
activities: RegistryActivity[];
areas: Record<string, GeoJSON.GeoJsonObject>;
class?: string;
} = $props();

/** Zoom level at which outlines are large enough that the centre dots only clutter. */
const DOTS_BELOW_ZOOM = 11;

let container: HTMLDivElement;
let leaflet = $state<typeof import('leaflet') | null>(null);
let map = $state<import('leaflet').Map | null>(null);
/** Each drawn outline by activity id, so the page can zoom to one. Not reactive. */
const outlinesById = new Map<string, import('leaflet').GeoJSON>();

/** Zooms to one activity's area and opens its popup. Returns false if it has none drawn. */
export function focusActivity(activityId: string): boolean {
const outline = outlinesById.get(activityId);
const bounds = outline?.getBounds();
if (!map || !outline || !bounds?.isValid()) return false;
map.fitBounds(bounds, { padding: [48, 48], maxZoom: 17 });
outline.openPopup(bounds.getCenter());
return true;
}

onMount(() => {
let destroyed = false;
let created: import('leaflet').Map | undefined;

(async () => {
const L = await import('leaflet');
if (destroyed) return;
created = L.map(container, { worldCopyJump: true }).setView([50, 10], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(created);
// The container is often still settling its size at init.
setTimeout(() => created?.invalidateSize(), 0);
leaflet = L;
map = created;
})();

return () => {
destroyed = true;
created?.remove();
};
});

// Redraw whenever the filtered set changes, and frame what is left.
$effect(() => {
const L = leaflet;
const m = map;
if (!L || !m) return;

const colours = statusColours();
const outlines = L.featureGroup();
const dots = L.layerGroup();

for (const activity of activities) {
const area = activity.activity_id ? areas[activity.activity_id] : undefined;
if (!area) continue;
const colour =
activity.verification_status === 'verified' ? colours.verified : colours.unverified;
const popup = popupHtml(activity);

const outline = L.geoJSON(area, {
style: { color: colour, weight: 2, fillColor: colour, fillOpacity: 0.25 }
}).bindPopup(popup);
outlines.addLayer(outline);
outlinesById.set(activity.activity_id!, outline);

const bounds = outline.getBounds();
if (bounds.isValid()) {
dots.addLayer(
L.circleMarker(bounds.getCenter(), {
radius: 7,
color: colours.halo,
weight: 2,
fillColor: colour,
fillOpacity: 1
}).bindPopup(popup)
);
}
}

outlines.addTo(m);
const syncDots = () => {
if (m.getZoom() < DOTS_BELOW_ZOOM) dots.addTo(m);
else dots.remove();
};
m.on('zoomend', syncDots);

const bounds = outlines.getBounds();
if (bounds.isValid()) m.fitBounds(bounds, { padding: [32, 32], maxZoom: 14 });
syncDots();

return () => {
m.off('zoomend', syncDots);
outlines.remove();
dots.remove();
outlinesById.clear();
};
});

/** Leaflet styles take colour strings, so read the design tokens off the document. */
function statusColours() {
const style = getComputedStyle(document.documentElement);
const token = (name: string) => style.getPropertyValue(name).trim();
return {
verified: token('--border-positive-strong'),
unverified: token('--border-warning-strong'),
halo: token('--surface-light')
};
}

function popupHtml(activity: RegistryActivity): string {
const name = escapeHtml(activity.name?.trim() || 'Unnamed activity');
const title = activity.activity_id
? `<a href="${escapeHtml(registryActivityHref(activity.activity_id))}">${name}</a>`
: name;
const status = activity.verification_status === 'verified' ? 'Verified' : 'Unverified';
const details = [
activity.activity_type,
[activity.city, activity.country_name].filter(Boolean).join(', '),
status
]
.filter((part) => typeof part === 'string' && part.trim() !== '')
.map((part) => escapeHtml(part as string))
.join(' · ');
return `<div class="ogcr-map-popup"><strong>${title}</strong><br />${details}</div>`;
}

function escapeHtml(value: string): string {
return value.replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!
);
}
</script>

<div bind:this={container} class="ogcr-registry-map {className}"></div>

<style>
.ogcr-registry-map {
width: 100%;
border-radius: var(--radius-l);
border: 1px solid var(--border-light);
background: var(--surface-neutral);
/* Keep Leaflet's panes under the site header and menus. */
isolation: isolate;
z-index: 0;
}

.ogcr-registry-map :global(.ogcr-map-popup) {
font-family: var(--font-family-default);
font-size: var(--font-size-s);
line-height: 1.5;
color: var(--text-secondary);
}
.ogcr-registry-map :global(.ogcr-map-popup strong) {
font-weight: 500;
color: var(--text-primary);
}

:global([data-mode='dark']) .ogcr-registry-map {
border-color: var(--color-surface-700);
background: var(--color-surface-800);
}
</style>
149 changes: 149 additions & 0 deletions src/lib/components/ToggleGroup.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<script lang="ts" module>
import type { Component } from 'svelte';

export interface ToggleGroupItem {
value: string;
label: string;
icon?: Component;
}
</script>

<script lang="ts">
/**
* OGCR Design System — ToggleGroup (spec §4.21), single-select segmented control.
*
* Ported from the shipped component; see ../../../design_system_integration.md
* for the porting conventions. Upstream wraps Base UI; this wraps bits-ui, which
* gives the same roving-focus keyboard model.
*
* API differences from upstream: single-select only, so `value` is a string rather
* than a string array (nothing needs `multiple` yet); and a press on the already
* selected segment keeps it selected, so the group always has a value.
*/
import { ToggleGroup } from 'bits-ui';

let {
items,
value = $bindable(),
onValueChange,
size = 'm',
'aria-label': ariaLabel,
class: className = ''
}: {
items: ToggleGroupItem[];
value: string;
onValueChange?: (value: string) => void;
size?: 's' | 'm';
'aria-label'?: string;
class?: string;
} = $props();
</script>

<ToggleGroup.Root
type="single"
{value}
onValueChange={(next) => {
if (!next) return;
value = next;
onValueChange?.(next);
}}
aria-label={ariaLabel}
class="ogcr-toggle-group {className}"
data-size={size}
>
{#each items as item (item.value)}
<ToggleGroup.Item value={item.value} class="ogcr-toggle-group__item">
{#if item.icon}
<span class="ogcr-toggle-group__icon" aria-hidden="true"><item.icon /></span>
{/if}
{item.label}
</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>

<style>
:global(.ogcr-toggle-group) {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: var(--radius-l);
background: var(--surface-neutral);
border: 1px solid var(--border-light);
}

:global(.ogcr-toggle-group__item) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
flex-shrink: 0;
height: 36px;
min-width: 36px;
padding: 0 var(--space-s);
border: 0;
border-radius: var(--radius-m);
background: transparent;
cursor: pointer;
user-select: none;
outline: none;
font-family: var(--font-family-default);
font-weight: 500;
font-size: var(--font-size-s);
line-height: 1;
color: var(--text-secondary);
transition:
color var(--motion-fast),
background-color var(--motion-fast),
box-shadow var(--motion-fast);
}
:global(.ogcr-toggle-group[data-size='s'] .ogcr-toggle-group__item) {
height: 28px;
min-width: 28px;
padding: 0 var(--space-xs);
}
:global(.ogcr-toggle-group__item:hover) {
color: var(--text-primary);
}
:global(.ogcr-toggle-group__item[data-state='on']) {
background: var(--surface-light);
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(68, 51, 33, 0.12);
}
:global(.ogcr-toggle-group__item:focus-visible) {
box-shadow:
0 0 0 2px var(--surface-light),
0 0 0 4px var(--interaction-primary-default);
}

.ogcr-toggle-group__icon {
display: inline-flex;
width: 20px;
height: 20px;
}
.ogcr-toggle-group__icon :global(svg) {
width: 100%;
height: 100%;
}

@media (prefers-reduced-motion: reduce) {
:global(.ogcr-toggle-group__item) {
transition: none;
}
}

:global([data-mode='dark'] .ogcr-toggle-group) {
background: var(--color-surface-800);
border-color: var(--color-surface-700);
}
:global([data-mode='dark'] .ogcr-toggle-group__item) {
color: var(--color-surface-400);
}
:global([data-mode='dark'] .ogcr-toggle-group__item:hover),
:global([data-mode='dark'] .ogcr-toggle-group__item[data-state='on']) {
color: var(--color-surface-50);
}
:global([data-mode='dark'] .ogcr-toggle-group__item[data-state='on']) {
background: var(--color-surface-900);
}
</style>
Loading
Loading