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)} - - {: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}