diff --git a/design_system_integration.md b/design_system_integration.md
index 96cf6f9..2fedeba 100644
--- a/design_system_integration.md
+++ b/design_system_integration.md
@@ -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 |
diff --git a/src/lib/components/RegistryActivitiesMap.svelte b/src/lib/components/RegistryActivitiesMap.svelte
new file mode 100644
index 0000000..a8bd15a
--- /dev/null
+++ b/src/lib/components/RegistryActivitiesMap.svelte
@@ -0,0 +1,188 @@
+
+
+
+
+
diff --git a/src/lib/components/ToggleGroup.svelte b/src/lib/components/ToggleGroup.svelte
new file mode 100644
index 0000000..707c5a9
--- /dev/null
+++ b/src/lib/components/ToggleGroup.svelte
@@ -0,0 +1,149 @@
+
+
+
+
+ {
+ if (!next) return;
+ value = next;
+ onValueChange?.(next);
+ }}
+ aria-label={ariaLabel}
+ class="ogcr-toggle-group {className}"
+ data-size={size}
+>
+ {#each items as item (item.value)}
+
+ {#if item.icon}
+
+ {/if}
+ {item.label}
+
+ {/each}
+
+
+
diff --git a/src/lib/registry/activityAreas.ts b/src/lib/registry/activityAreas.ts
new file mode 100644
index 0000000..4c3db82
--- /dev/null
+++ b/src/lib/registry/activityAreas.ts
@@ -0,0 +1,77 @@
+import { env } from '$env/dynamic/public';
+import { REGISTRY_ACTIVITY_PATH } from '$lib/registry/activityDetail';
+
+/**
+ * The land each registry activity covers, for the registry map.
+ *
+ * The registry list Query does not project `multipolygon_coordinates` (it would make
+ * every list load carry every polygon), so the map reads the activity detail Query
+ * unfiltered instead: one anonymous call, every activity's area. Only the map view
+ * makes this call.
+ *
+ * Parcel records themselves are not readable anonymously; the activity's own
+ * multipolygon is the public record of the land it covers.
+ */
+
+export interface ActivityAreasResult {
+ /** activity_id → GeoJSON geometry. Activities with no usable area are absent. */
+ areas: Record;
+ error: string | null;
+}
+
+/** Never throws; callers render `error` themselves. */
+export async function getRegistryActivityAreas(
+ fetchFn: typeof fetch = fetch
+): Promise {
+ const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? '';
+ try {
+ const response = await fetchFn(`${base}${REGISTRY_ACTIVITY_PATH}`);
+ if (!response.ok) {
+ return {
+ areas: {},
+ error: `Registry returned HTTP ${response.status} from ${REGISTRY_ACTIVITY_PATH}`
+ };
+ }
+ const body = (await response.json()) as {
+ activities?: { activity_id?: string | null; multipolygon_coordinates?: unknown }[];
+ };
+ const areas: Record = {};
+ for (const row of body.activities ?? []) {
+ const geometry = toGeoJson(row.multipolygon_coordinates);
+ if (row.activity_id && geometry) areas[row.activity_id] = geometry;
+ }
+ return { areas, error: null };
+ } catch (error) {
+ return {
+ areas: {},
+ error: error instanceof Error ? error.message : 'Could not reach the registry'
+ };
+ }
+}
+
+/**
+ * `multipolygon_coordinates` is stored either as GeoJSON (a geometry, Feature or
+ * FeatureCollection) or as a bare MultiPolygon coordinate array, and either way may
+ * arrive as a JSON string. Null when it is empty or unparseable.
+ */
+export function toGeoJson(raw: unknown): GeoJSON.GeoJsonObject | null {
+ if (raw === null || raw === undefined || raw === '') return null;
+ try {
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
+ if (Array.isArray(value)) {
+ return value.length
+ ? ({ type: 'MultiPolygon', coordinates: value } as GeoJSON.MultiPolygon)
+ : null;
+ }
+ if (
+ value &&
+ typeof value === 'object' &&
+ typeof (value as { type?: unknown }).type === 'string'
+ ) {
+ return value as GeoJSON.GeoJsonObject;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/registry/areaOverlaps.test.ts b/src/lib/registry/areaOverlaps.test.ts
new file mode 100644
index 0000000..f9acf82
--- /dev/null
+++ b/src/lib/registry/areaOverlaps.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { findAreaOverlaps } from './areaOverlaps';
+
+/** An axis-aligned square polygon, open ring (the module closes it). */
+function square(x: number, y: number, size: number): GeoJSON.Polygon {
+ return {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [x, y],
+ [x + size, y],
+ [x + size, y + size],
+ [x, y + size]
+ ]
+ ]
+ };
+}
+
+const pairs = (overlaps: Map) =>
+ Object.fromEntries([...overlaps].map(([id, others]) => [id, [...others].sort()]));
+
+describe('findAreaOverlaps', () => {
+ it('flags parcels whose edges cross', () => {
+ expect(pairs(findAreaOverlaps({ a: square(0, 0, 1), b: square(0.5, 0.5, 1) }))).toEqual({
+ a: ['b'],
+ b: ['a']
+ });
+ });
+
+ it('flags identical parcels', () => {
+ expect(pairs(findAreaOverlaps({ a: square(0, 0, 1), b: square(0, 0, 1) }))).toEqual({
+ a: ['b'],
+ b: ['a']
+ });
+ });
+
+ it('flags a parcel inside another', () => {
+ expect(pairs(findAreaOverlaps({ a: square(0, 0, 4), b: square(1, 1, 1) }))).toEqual({
+ a: ['b'],
+ b: ['a']
+ });
+ });
+
+ it('flags parcels that share part of an edge and some ground', () => {
+ expect(pairs(findAreaOverlaps({ a: square(0, 0, 1), b: square(0.5, 0, 1) }))).toEqual({
+ a: ['b'],
+ b: ['a']
+ });
+ });
+
+ it('does not flag neighbours that only share a boundary', () => {
+ expect(findAreaOverlaps({ a: square(0, 0, 1), b: square(1, 0, 1) }).size).toBe(0);
+ });
+
+ it('does not flag parcels that touch at a corner', () => {
+ expect(findAreaOverlaps({ a: square(0, 0, 1), b: square(1, 1, 1) }).size).toBe(0);
+ });
+
+ it('does not flag a parcel sitting in another’s hole', () => {
+ const ringed: GeoJSON.Polygon = {
+ type: 'Polygon',
+ coordinates: [square(0, 0, 4).coordinates[0], square(1, 1, 2).coordinates[0]]
+ };
+ expect(findAreaOverlaps({ a: ringed, b: square(1.5, 1.5, 1) }).size).toBe(0);
+ });
+
+ it('reads MultiPolygons and Features and lists every overlapping activity', () => {
+ const multi: GeoJSON.MultiPolygon = {
+ type: 'MultiPolygon',
+ coordinates: [square(10, 10, 1).coordinates, square(0, 0, 1).coordinates]
+ };
+ const feature: GeoJSON.Feature = {
+ type: 'Feature',
+ properties: {},
+ geometry: square(0.2, 0.2, 0.5)
+ };
+ expect(pairs(findAreaOverlaps({ a: multi, b: feature, c: square(10.5, 10.5, 1) }))).toEqual({
+ a: ['b', 'c'],
+ b: ['a'],
+ c: ['a']
+ });
+ });
+});
diff --git a/src/lib/registry/areaOverlaps.ts b/src/lib/registry/areaOverlaps.ts
new file mode 100644
index 0000000..1bbb365
--- /dev/null
+++ b/src/lib/registry/areaOverlaps.ts
@@ -0,0 +1,227 @@
+/**
+ * Which registry activities' areas overlap another's, for the map's quick list.
+ *
+ * Land claimed by two activities (often under different certification schemes) is a
+ * double-counting risk, so the map flags it. Parcels are small enough to treat
+ * longitude/latitude as a plane.
+ *
+ * Two areas overlap when their interiors share ground. Parcels that only touch along
+ * a shared boundary, as neighbouring fields do, do not count. The test is: an edge of
+ * one properly crosses an edge of the other, or a sample point of one (a vertex, an
+ * edge midpoint or the ring's centroid) lies strictly inside the other. The sample
+ * points catch the cases with no crossing: identical parcels, one inside the other,
+ * and parcels that share part of an edge.
+ */
+
+type Point = [number, number];
+type Ring = Point[];
+/** Outer ring first, then holes. */
+type Polygon = Ring[];
+
+interface Bounds {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+}
+
+interface Area {
+ id: string;
+ polygons: Polygon[];
+ bounds: Bounds;
+}
+
+/** activity_id → the ids of the other activities whose areas overlap it. Only overlapping ids appear. */
+export function findAreaOverlaps(
+ areas: Record
+): Map {
+ const prepared: Area[] = [];
+ for (const [id, geometry] of Object.entries(areas)) {
+ const polygons = polygonsOf(geometry);
+ const bounds = boundsOf(polygons);
+ if (bounds) prepared.push({ id, polygons, bounds });
+ }
+
+ const overlaps = new Map();
+ const add = (a: string, b: string) => {
+ const list = overlaps.get(a);
+ if (list) list.push(b);
+ else overlaps.set(a, [b]);
+ };
+
+ for (let i = 0; i < prepared.length; i++) {
+ for (let j = i + 1; j < prepared.length; j++) {
+ const a = prepared[i];
+ const b = prepared[j];
+ if (!boundsIntersect(a.bounds, b.bounds)) continue;
+ if (areasOverlap(a.polygons, b.polygons)) {
+ add(a.id, b.id);
+ add(b.id, a.id);
+ }
+ }
+ }
+ return overlaps;
+}
+
+function areasOverlap(a: Polygon[], b: Polygon[]): boolean {
+ return a.some((pa) => b.some((pb) => polygonsOverlap(pa, pb)));
+}
+
+function polygonsOverlap(a: Polygon, b: Polygon): boolean {
+ for (const ringA of a) {
+ for (const ringB of b) {
+ if (ringsCross(ringA, ringB)) return true;
+ }
+ }
+ return (
+ samplePoints(a).some((p) => strictlyInside(p, b)) ||
+ samplePoints(b).some((p) => strictlyInside(p, a))
+ );
+}
+
+function ringsCross(a: Ring, b: Ring): boolean {
+ for (let i = 0; i < a.length - 1; i++) {
+ for (let j = 0; j < b.length - 1; j++) {
+ if (segmentsCrossProperly(a[i], a[i + 1], b[j], b[j + 1])) return true;
+ }
+ }
+ return false;
+}
+
+/** True only when the segments cross at a point inside both; touching or collinear is not a crossing. */
+function segmentsCrossProperly(p1: Point, p2: Point, q1: Point, q2: Point): boolean {
+ const d1 = orientation(q1, q2, p1);
+ const d2 = orientation(q1, q2, p2);
+ const d3 = orientation(p1, p2, q1);
+ const d4 = orientation(p1, p2, q2);
+ return d1 * d2 < 0 && d3 * d4 < 0;
+}
+
+/** Sign of the turn a → b → c, with near-zero treated as collinear. */
+function orientation(a: Point, b: Point, c: Point): number {
+ const value = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
+ return Math.abs(value) < 1e-18 ? 0 : Math.sign(value);
+}
+
+function samplePoints(polygon: Polygon): Point[] {
+ const outer = polygon[0];
+ const points: Point[] = [];
+ for (let i = 0; i < outer.length - 1; i++) {
+ points.push(outer[i]);
+ points.push([(outer[i][0] + outer[i + 1][0]) / 2, (outer[i][1] + outer[i + 1][1]) / 2]);
+ }
+ // A holed or concave parcel's centroid can fall outside its own ground.
+ const centroid = ringCentroid(outer);
+ if (centroid && strictlyInside(centroid, polygon)) points.push(centroid);
+ return points;
+}
+
+function ringCentroid(ring: Ring): Point | null {
+ let area = 0;
+ let x = 0;
+ let y = 0;
+ for (let i = 0; i < ring.length - 1; i++) {
+ const cross = ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
+ area += cross;
+ x += (ring[i][0] + ring[i + 1][0]) * cross;
+ y += (ring[i][1] + ring[i + 1][1]) * cross;
+ }
+ if (area === 0) return null;
+ return [x / (3 * area), y / (3 * area)];
+}
+
+/** Inside the outer ring and outside every hole, and on none of their boundaries. */
+function strictlyInside(point: Point, polygon: Polygon): boolean {
+ const [outer, ...holes] = polygon;
+ if (ringPosition(point, outer) !== 'in') return false;
+ return holes.every((hole) => ringPosition(point, hole) === 'out');
+}
+
+function ringPosition(point: Point, ring: Ring): 'in' | 'on' | 'out' {
+ let inside = false;
+ for (let i = 0; i < ring.length - 1; i++) {
+ const a = ring[i];
+ const b = ring[i + 1];
+ if (onSegment(point, a, b)) return 'on';
+ if (a[1] > point[1] !== b[1] > point[1]) {
+ const x = a[0] + ((point[1] - a[1]) * (b[0] - a[0])) / (b[1] - a[1]);
+ if (point[0] < x) inside = !inside;
+ }
+ }
+ return inside ? 'in' : 'out';
+}
+
+function onSegment(p: Point, a: Point, b: Point): boolean {
+ return (
+ orientation(a, b, p) === 0 &&
+ p[0] >= Math.min(a[0], b[0]) &&
+ p[0] <= Math.max(a[0], b[0]) &&
+ p[1] >= Math.min(a[1], b[1]) &&
+ p[1] <= Math.max(a[1], b[1])
+ );
+}
+
+/** Every polygon in a geometry, Feature or FeatureCollection, with each ring closed. */
+function polygonsOf(value: unknown): Polygon[] {
+ if (!value || typeof value !== 'object') return [];
+ const object = value as {
+ type?: string;
+ coordinates?: unknown;
+ geometry?: unknown;
+ features?: unknown[];
+ geometries?: unknown[];
+ };
+ switch (object.type) {
+ case 'Polygon':
+ return [toPolygon(object.coordinates)].filter((p): p is Polygon => p !== null);
+ case 'MultiPolygon':
+ return Array.isArray(object.coordinates)
+ ? object.coordinates.map(toPolygon).filter((p): p is Polygon => p !== null)
+ : [];
+ case 'Feature':
+ return polygonsOf(object.geometry);
+ case 'FeatureCollection':
+ return (object.features ?? []).flatMap(polygonsOf);
+ case 'GeometryCollection':
+ return (object.geometries ?? []).flatMap(polygonsOf);
+ default:
+ return [];
+ }
+}
+
+function toPolygon(raw: unknown): Polygon | null {
+ if (!Array.isArray(raw)) return null;
+ const rings = raw.map(toRing).filter((r): r is Ring => r !== null);
+ return rings.length ? rings : null;
+}
+
+function toRing(raw: unknown): Ring | null {
+ if (!Array.isArray(raw)) return null;
+ const points = raw.filter(
+ (p): p is Point => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1])
+ );
+ if (points.length < 3) return null;
+ const [first, last] = [points[0], points[points.length - 1]];
+ if (first[0] !== last[0] || first[1] !== last[1]) points.push(first);
+ return points;
+}
+
+function boundsOf(polygons: Polygon[]): Bounds | null {
+ let bounds: Bounds | null = null;
+ for (const polygon of polygons) {
+ for (const [x, y] of polygon[0]) {
+ if (!bounds) bounds = { minX: x, minY: y, maxX: x, maxY: y };
+ else {
+ bounds.minX = Math.min(bounds.minX, x);
+ bounds.minY = Math.min(bounds.minY, y);
+ bounds.maxX = Math.max(bounds.maxX, x);
+ bounds.maxY = Math.max(bounds.maxY, y);
+ }
+ }
+ }
+ return bounds;
+}
+
+function boundsIntersect(a: Bounds, b: Bounds): boolean {
+ return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY;
+}
diff --git a/src/routes/design/+page.svelte b/src/routes/design/+page.svelte
index 1283c79..21a36b0 100644
--- a/src/routes/design/+page.svelte
+++ b/src/routes/design/+page.svelte
@@ -199,6 +199,12 @@
{ name: 'KPI', spec: '§4.11', ported: false, note: '' },
{ name: 'Table', spec: '§4.14', ported: false, note: '' },
{ name: 'Select / Combobox', spec: '§4.17 / §4.18', ported: false, note: 'build on bits-ui' },
+ {
+ name: 'Toggle (group)',
+ spec: '§4.21',
+ ported: true,
+ note: 'src/lib/components/ToggleGroup.svelte — single-select, on bits-ui'
+ },
{ name: 'Tabs', spec: '§4.23', ported: false, note: 'build on bits-ui' },
{ name: 'Breadcrumb', spec: '§4.26', ported: false, note: '' },
{ name: 'Pagination', spec: '§4.27', ported: false, note: '' },
diff --git a/src/routes/registry/activities/+page.server.ts b/src/routes/registry/activities/+page.server.ts
index 45a3159..7a3882b 100644
--- a/src/routes/registry/activities/+page.server.ts
+++ b/src/routes/registry/activities/+page.server.ts
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { getRegistryActivities } from '$lib/registry/activities';
+import { getRegistryActivityAreas } from '$lib/registry/activityAreas';
/**
* Public: no access token. One call to the registry Dynamic Resource Doc, which has
@@ -9,8 +10,23 @@ import { getRegistryActivities } from '$lib/registry/activities';
* while the registry is small, and it keeps filtering instant. If this ever holds the
* "hundreds of activities" the brief anticipates, push search/filter/sort down into the
* resource doc as query parameters rather than paginating client-side.
+ *
+ * `?view=map` also reads every activity's area for the map. The list view does not
+ * pay for that call; switching views re-runs this loader, and the page's filters
+ * survive because the component is reused.
*/
-export const load: PageServerLoad = async ({ fetch }) => {
- const { activities, count, error } = await getRegistryActivities(fetch);
- return { activities, count, error };
+export const load: PageServerLoad = async ({ fetch, url }) => {
+ const view = url.searchParams.get('view') === 'map' ? 'map' : 'list';
+ const [{ activities, count, error }, areas] = await Promise.all([
+ getRegistryActivities(fetch),
+ view === 'map' ? getRegistryActivityAreas(fetch) : null
+ ]);
+ return {
+ activities,
+ count,
+ error,
+ view,
+ areas: areas?.areas ?? {},
+ areasError: areas?.error ?? null
+ };
};
diff --git a/src/routes/registry/activities/+page.svelte b/src/routes/registry/activities/+page.svelte
index 2ca43c6..3dcb4ab 100644
--- a/src/routes/registry/activities/+page.svelte
+++ b/src/routes/registry/activities/+page.svelte
@@ -3,6 +3,10 @@
import Table, { type TableColumn } from '$lib/components/Table.svelte';
import Pill from '$lib/components/Pill.svelte';
import ObpErrorDisplay from '$lib/components/ObpErrorDisplay.svelte';
+ import ToggleGroup from '$lib/components/ToggleGroup.svelte';
+ import RegistryActivitiesMap from '$lib/components/RegistryActivitiesMap.svelte';
+ import { goto } from '$app/navigation';
+ import { page } from '$app/state';
import {
distinctValues,
mintedAtIso,
@@ -11,7 +15,8 @@
registryOperatorHref,
type RegistryActivity
} from '$lib/registry/activities';
- import { Search } from '@lucide/svelte';
+ import { Search, List, Map as MapIcon, Layers } from '@lucide/svelte';
+ import { findAreaOverlaps } from '$lib/registry/areaOverlaps';
let { data }: { data: PageData } = $props();
@@ -22,6 +27,20 @@
const activities = $derived(data.activities as RegistryActivity[]);
+ // The view lives in the URL (?view=map) so it can be linked and survives a reload.
+ // The toggle flips at once; the content follows when the loader has the areas.
+ let view = $derived(data.view);
+ const viewItems = [
+ { value: 'list', label: 'List', icon: List },
+ { value: 'map', label: 'Map', icon: MapIcon }
+ ];
+ function showView(next: string) {
+ const url = new URL(page.url);
+ if (next === 'map') url.searchParams.set('view', 'map');
+ else url.searchParams.delete('view');
+ goto(url, { noScroll: true, keepFocus: true });
+ }
+
// Dropdowns are populated from the values actually present, so they never offer a
// filter that would return nothing.
const creditTypes = $derived(distinctValues(activities, 'activity_type'));
@@ -39,6 +58,40 @@
})
);
+ /**
+ * Areas that share ground with another activity's: possible double counting, e.g. the
+ * same field certified under two schemes. Checked across every activity, not just the
+ * filtered ones, so narrowing the list never hides who else claims the land.
+ */
+ const overlaps = $derived(findAreaOverlaps(data.areas));
+ const activityNames = $derived(
+ new Map(activities.map((a) => [a.activity_id, a.name?.trim() || 'Unnamed activity']))
+ );
+ const overlapsWith = (activity: RegistryActivity) =>
+ (activity.activity_id && overlaps.get(activity.activity_id)) || [];
+
+ /**
+ * The filtered activities that have an area to draw, for the map's quick list:
+ * overlapping ones first, then by name.
+ */
+ const mapped = $derived(
+ filtered
+ .filter((a) => a.activity_id && data.areas[a.activity_id])
+ .sort(
+ (a, b) =>
+ Number(overlapsWith(b).length > 0) - Number(overlapsWith(a).length > 0) ||
+ (a.name ?? '').localeCompare(b.name ?? '')
+ )
+ );
+ const overlappingCount = $derived(mapped.filter((a) => overlapsWith(a).length > 0).length);
+ const mappedCount = $derived(mapped.length);
+
+ let activitiesMap = $state>();
+ let focusedId = $state(null);
+ function focusOnMap(activityId: string) {
+ if (activitiesMap?.focusActivity(activityId)) focusedId = activityId;
+ }
+
const filtersApplied = $derived(
search.trim() !== '' || creditType !== 'all' || country !== 'all' || status !== 'all'
);
@@ -105,7 +158,15 @@
-
Activities
+
+
Activities
+
+
{#if data.error}
@@ -157,114 +218,265 @@
{/if}
-
+
{filtered.length === data.count
? `${data.count} ${data.count === 1 ? 'activity' : 'activities'}`
: `${filtered.length} of ${data.count} activities`}
+ {#if data.view === 'map' && !data.areasError}
+ · {mappedCount === filtered.length ? 'all' : mappedCount} on the map{#if mappedCount < filtered.length}
+ ({filtered.length - mappedCount} with no recorded area){/if}
+ {/if}
-
- {#snippet cell(row, column)}
-
+
+ {#each mapped as activity (activity.activity_id)}
+ {@const others = overlapsWith(activity).map((id) => activityNames.get(id) ?? id)}
+ -
+
+
+ {/each}
+
+ {/if}
+
+ {:else}
+
+ {#snippet cell(row, column)}
+
- {@const activityHref =
- typeof row.activity_id === 'string' ? registryActivityHref(row.activity_id) : null}
- {#if column.key === 'name'}
- {#if activityHref}
- {show(row.name)}
- {:else}
- {show(row.name)}
- {/if}
- {:else if column.key === 'activity_type'}
- {#if typeof row.activity_type === 'string' && row.activity_type}
-
- {:else}
- {DASH}
- {/if}
- {:else if column.key === 'verification_status'}
-
- {@const pill = row.verification_status === 'verified' ? 'Verified' : 'Unverified'}
- {#if activityHref}
-
+ {@const activityHref =
+ typeof row.activity_id === 'string' ? registryActivityHref(row.activity_id) : null}
+ {#if column.key === 'name'}
+ {#if activityHref}
+ {show(row.name)}
+ {:else}
+ {show(row.name)}
+ {/if}
+ {:else if column.key === 'activity_type'}
+ {#if typeof row.activity_type === 'string' && row.activity_type}
+
+ {:else}
+ {DASH}
+ {/if}
+ {:else if column.key === 'verification_status'}
+
+ {@const pill = row.verification_status === 'verified' ? 'Verified' : 'Unverified'}
+ {#if activityHref}
+
+
+ {pill}
+
+
+ {:else}
{pill}
-
- {:else}
-
- {pill}
-
- {/if}
- {:else if column.key === 'certificate'}
- {#if typeof row.certificate_of_compliance_id === 'string' && row.certificate_of_compliance_id}
-
- View certificate
-
- {:else}
- Not yet certified
- {/if}
- {:else if column.key === 'minted'}
- {#if row.minted && activityHref}
- {row.minted}
- {:else if row.minted}
- {row.minted}
- {:else}
- Not minted
- {/if}
- {:else if column.key === 'location'}
- {#if typeof row.country_name === 'string' && row.country_name}
-
- {:else}
- {show(row.location)}
- {/if}
- {:else if column.key === 'operator_legal_name'}
- {#if typeof row.operator_id === 'string' && row.operator_id}
-
- {show(row.operator_legal_name ?? row.operator_id)}
+ {/if}
+ {:else if column.key === 'operator_legal_name'}
+ {#if typeof row.operator_id === 'string' && row.operator_id}
+
+ {show(row.operator_legal_name ?? row.operator_id)}
+
+ {:else}
+ {show(row.operator_legal_name)}
+ {/if}
+ {:else if activityHref}
+
+
+ {show(row[column.key])}
{:else}
- {show(row.operator_legal_name)}
- {/if}
- {:else if activityHref}
-
-
{show(row[column.key])}
-
- {:else}
- {show(row[column.key])}
- {/if}
- {/snippet}
-
+ {/if}
+ {/snippet}
+
+ {/if}