File size: 8,284 Bytes
9dff1b6
 
 
caa28aa
9dff1b6
 
 
 
 
 
 
 
 
 
caa28aa
 
 
9dff1b6
 
caa28aa
9dff1b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
caa28aa
9dff1b6
 
 
 
 
caa28aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9dff1b6
 
caa28aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9dff1b6
caa28aa
 
 
 
 
 
 
 
 
9dff1b6
 
 
 
 
 
 
 
 
 
caa28aa
9dff1b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
<script lang="ts">
  import Briefing from './Briefing.svelte';
  import { parseBriefing } from '$lib/client/parseBriefing';
  import type { Citation } from '$lib/types/claim';

  interface Target {
    label: string;
    address: string;
  }

  interface Props {
    paragraph: string;
    citations: Record<string, Citation>;
    targets: Target[];
    /** Per-place step result payloads from the agent stream, keyed by step name. */
    structuredA?: Record<string, unknown>;
    structuredB?: Record<string, unknown>;
  }

  let { paragraph, citations, targets, structuredA = {}, structuredB = {} }: Props = $props();

  // Split the merged compare paragraph at the --- divider.
  // Each half begins with `## PLACE A/B: <address>` which we strip to get
  // clean 4-section markdown for parseBriefing.
  function splitParagraph(para: string): { address: string; md: string }[] {
    const halves = para.split(/\n\s*---\s*\n/, 2);
    return halves.map((half, i) => {
      const m = /^##\s+PLACE\s+[AB]:\s+(.+?)(\n|$)/m.exec(half.trim());
      const address = m?.[1]?.trim() ?? targets[i]?.address ?? `Place ${String.fromCharCode(65 + i)}`;
      const md = half.replace(/^##\s+PLACE\s+[AB]:\s+.+(\n|$)/m, '').trim();
      return { address, md };
    });
  }

  const halves = $derived(splitParagraph(paragraph));
  const parsedA = $derived(parseBriefing(halves[0]?.md ?? '', citations));
  const parsedB = $derived(parseBriefing(halves[1]?.md ?? '', citations));

  // Both columns share the merged citation registry so cross-column
  // doc_id numbering stays consistent.
  const allCitations = $derived({
    ...citations,
    ...parsedA.citations,
    ...parsedB.citations
  });

  interface DeltaRow {
    label: string;
    ctx: string;
    aVal: string;
    bVal: string;
  }

  function getNum(steps: Record<string, unknown>, stepName: string, field: string): number | undefined {
    const r = steps[stepName];
    if (!r || typeof r !== 'object') return undefined;
    const v = (r as Record<string, unknown>)[field];
    return typeof v === 'number' ? v : undefined;
  }

  function getBool(steps: Record<string, unknown>, stepName: string, field: string): boolean | undefined {
    const r = steps[stepName];
    if (!r || typeof r !== 'object') return undefined;
    const v = (r as Record<string, unknown>)[field];
    return typeof v === 'boolean' ? v : undefined;
  }

  // Derive diff rows from structured specialist step payloads.
  // This avoids parsing prose for numbers, which incorrectly picks up
  // address street numbers as "Status" comparisons.
  const deltaRows = $derived.by<DeltaRow[]>(() => {
    const rows: DeltaRow[] = [];

    // Sandy inundation zone membership
    const sandyA = getBool(structuredA, 'sandy_inundation', 'inside');
    const sandyB = getBool(structuredB, 'sandy_inundation', 'inside');
    if (sandyA !== undefined && sandyB !== undefined && sandyA !== sandyB) {
      rows.push({ label: 'Sandy zone', ctx: '', aVal: sandyA ? 'inside' : 'outside', bVal: sandyB ? 'inside' : 'outside' });
    }

    // 311 flood complaints (5-year radius)
    const n311A = getNum(structuredA, 'nyc311', 'n');
    const n311B = getNum(structuredB, 'nyc311', 'n');
    if (n311A !== undefined && n311B !== undefined && n311A !== n311B) {
      rows.push({ label: '311 complaints', ctx: '5 y', aVal: String(n311A), bVal: String(n311B) });
    }

    // Terrain elevation
    const elevA = getNum(structuredA, 'microtopo_lidar', 'elev_m');
    const elevB = getNum(structuredB, 'microtopo_lidar', 'elev_m');
    if (elevA !== undefined && elevB !== undefined && Math.abs(elevA - elevB) > 0.5) {
      rows.push({ label: 'Elevation', ctx: '', aVal: `${elevA.toFixed(1)} m`, bVal: `${elevB.toFixed(1)} m` });
    }

    // FloodNet sensor flood events (3-year)
    const fnA = getNum(structuredA, 'floodnet', 'n_events_3y');
    const fnB = getNum(structuredB, 'floodnet', 'n_events_3y');
    if (fnA !== undefined && fnB !== undefined && fnA !== fnB) {
      rows.push({ label: 'Sensor events', ctx: 'last 3 y', aVal: String(fnA), bVal: String(fnB) });
    }

    // Ida 2021 high-water mark (nearest within 800 m)
    const idaA = getNum(structuredA, 'ida_hwm_2021', 'max_height_above_gnd_ft');
    const idaB = getNum(structuredB, 'ida_hwm_2021', 'max_height_above_gnd_ft');
    if (idaA !== undefined && idaB !== undefined && Math.abs(idaA - idaB) > 0.1) {
      rows.push({ label: 'Ida 2021 HWM', ctx: 'ft above gnd', aVal: `${idaA.toFixed(2)} ft`, bVal: `${idaB.toFixed(2)} ft` });
    }

    return rows.slice(0, 4);
  });
</script>

<div class="compare-layout">
  {#if deltaRows.length > 0}
    <div class="compare-delta-bar" aria-label="Key differences">
      <span class="compare-delta-title">Key differences</span>
      <div class="compare-delta-rows">
        {#each deltaRows as row}
          <div class="compare-delta-row">
            <span class="compare-delta-section">{row.label}</span>
            <span class="compare-delta-claim">
              {#if row.ctx}<span class="compare-delta-ctx">{row.ctx}:</span>{/if}
              <strong class="compare-delta-a">{row.aVal}</strong>
              <span class="compare-delta-vs"> vs </span>
              <strong class="compare-delta-b">{row.bVal}</strong>
            </span>
          </div>
        {/each}
      </div>
    </div>
  {/if}

  <div class="compare-cols">
    {#each halves as half, i}
      <div class="compare-col">
        <h2 class="compare-address-header address-header">
          {halves[i].address}
        </h2>
        <Briefing
          blocks={i === 0 ? parsedA.blocks : parsedB.blocks}
          citations={allCitations}
          streaming={false}
        />
      </div>
      {#if i === 0}
        <div class="compare-divider" role="separator" aria-hidden="true"></div>
      {/if}
    {/each}
  </div>
</div>

<style>
  .compare-layout {
    width: 100%;
  }

  /* Delta summary bar — above both columns */
  .compare-delta-bar {
    border: 1px solid var(--rule-soft);
    background: var(--paper-deep);
    padding: var(--s-3) var(--s-4);
    margin-bottom: var(--s-5);
    display: flex;
    gap: var(--s-4);
    align-items: flex-start;
    flex-wrap: wrap;
  }
  .compare-delta-title {
    font-family: var(--font-mono);
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--ink-tertiary);
    flex-shrink: 0;
    padding-top: 1px;
  }
  .compare-delta-rows {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2) var(--s-5);
    flex: 1;
  }
  .compare-delta-row {
    display: inline-flex;
    align-items: baseline;
    gap: var(--s-2);
    font-family: var(--font-mono);
    font-size: 12px;
  }
  .compare-delta-section {
    color: var(--ink-tertiary);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-size: 10px;
    flex-shrink: 0;
  }
  .compare-delta-claim {
    color: var(--ink);
    display: inline-flex;
    align-items: baseline;
    gap: 3px;
  }
  .compare-delta-ctx {
    color: var(--ink-secondary);
    margin-right: 2px;
  }
  .compare-delta-a,
  .compare-delta-b {
    color: var(--accent);
    font-weight: 600;
  }
  .compare-delta-vs {
    color: var(--ink-tertiary);
    font-style: italic;
  }

  /* Two-column layout on desktop */
  .compare-cols {
    display: grid;
    grid-template-columns: 1fr 1px 1fr;
    gap: 0 var(--s-5);
    align-items: start;
  }
  .compare-col {
    min-width: 0;
  }
  /* Vertical rule between the two columns */
  .compare-divider {
    background: var(--rule-soft);
    align-self: stretch;
  }

  /* Address header — same mono treatment as .region-head-meta but larger */
  .compare-address-header {
    font-family: var(--font-mono);
    font-size: 13px;
    font-weight: 600;
    letter-spacing: 0.04em;
    color: var(--ink);
    border-bottom: 1px solid var(--rule-soft);
    padding-bottom: var(--s-2);
    margin-top: 0;
    margin-bottom: var(--s-4);
    line-height: 1.4;
  }

  /* Narrow viewport (< 900 px): stack columns vertically */
  @media (max-width: 899px) {
    .compare-cols {
      grid-template-columns: 1fr;
      gap: 0;
    }
    .compare-divider {
      width: 100%;
      height: 1px;
      margin: var(--s-5) 0;
      align-self: auto;
    }
  }
</style>