-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathexplorer.qmd
More file actions
3411 lines (3188 loc) · 156 KB
/
explorer.qmd
File metadata and controls
3411 lines (3188 loc) · 156 KB
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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
---
title: "Interactive Explorer"
subtitle: "Search and explore 6.7 million material samples"
categories: [parquet, spatial, h3, performance, isamples]
sidebar: false
# No TOC: this page is an app, not an article. The right-hand TOC sidebar
# (#quarto-margin-sidebar) was overlapping .side-panel and silently
# intercepting clicks on the Source filter checkboxes — see issue #127.
toc: false
format:
html:
include-in-header:
text: |
<link rel="preconnect" href="https://data.isamples.org" crossorigin>
<link rel="preload" as="fetch" crossorigin="anonymous" href="https://data.isamples.org/isamples_202601_h3_summary_res4.parquet">
<link rel="preload" as="fetch" crossorigin="anonymous" href="https://data.isamples.org/isamples_202601_facet_summaries.parquet">
<link rel="preload" as="fetch" crossorigin="anonymous" href="https://data.isamples.org/vocab_labels.parquet">
---
```{=html}
<script src="https://cesium.com/downloads/cesiumjs/releases/1.127/Build/Cesium/Cesium.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.127/Build/Cesium/Widgets/widgets.css" rel="stylesheet"></link>
<style>
:root {
/* Map is shorter now that the samples table is permanent below it
(issue #200 / M-5). The grid sidebar tracks this height. */
--explorer-map-height: clamp(400px, 50vh, 540px);
--explorer-shell-width: min(1420px, calc(100vw - 64px));
}
html {
scrollbar-gutter: stable;
}
#quarto-document-content {
max-width: none;
width: var(--explorer-shell-width);
margin-left: 50%;
transform: translateX(-50%);
}
div.cesium-topleft {
display: block;
position: absolute;
background: #00000099;
color: white;
height: auto;
z-index: 999;
}
.globe-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 12px;
margin-bottom: 16px;
}
@media (max-width: 900px) {
:root {
--explorer-map-height: clamp(360px, 50vh, 480px);
--explorer-shell-width: calc(100vw - 32px);
}
.globe-layout { grid-template-columns: 1fr; }
}
.map-wrap {
position: relative;
width: 100%;
}
#cesiumContainer {
width: 100%;
height: var(--explorer-map-height);
min-height: 0;
aspect-ratio: auto;
}
/* Slim top-right search overlay (Hana Figma node 222:456). The earlier
multi-row treatment (M-1A, PR #200) ate ~480px × ~100px on the left
side of the map; this collapses to one row at the right. The
Cesium toolbar is still at top-left (left:5px), so positioning at
top-right keeps both surfaces clear of each other. */
.map-search-overlay {
position: absolute;
top: 8px;
right: 8px;
z-index: 1000;
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(4px);
padding: 0 4px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
height: 32px; /* slim row; Figma 222:456 target is ~28 + 4 chrome */
box-sizing: border-box;
}
.map-search-overlay input[type="text"] {
width: 260px;
max-width: 100%;
height: 24px;
padding: 0 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
outline: none;
background: white;
box-sizing: border-box;
}
.map-search-overlay input[type="text"]:focus {
border-color: #1565c0;
box-shadow: 0 0 0 2px rgba(21, 101, 192, 0.15);
}
.map-search-overlay #searchSubmitBtn {
background: #1565c0;
color: white;
border: none;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 14px;
box-sizing: border-box;
flex-shrink: 0;
}
.map-search-overlay #searchSubmitBtn:hover { background: #0d47a1; }
.map-search-overlay #searchSubmitBtn:focus-visible {
outline: 2px solid #0d47a1;
outline-offset: 2px;
}
/* Scope buttons + help paragraph are preserved in the DOM (handlers in
zoomWatcher / search wiring bind by id) but hidden in the slim
layout. Area-scoped search is still reachable via `?search_scope=area`
URL hydration, which calls doSearch('area') on boot. */
.map-search-overlay .search-actions,
.map-search-overlay .search-help { display: none; }
/* The aria-live results-count line sits just under the overlay at the
same right edge. Hidden visually when empty so it doesn't add a
blank strip; aria-live still announces. */
.map-search-results-line {
position: absolute;
top: 46px;
right: 8px;
max-width: 340px;
z-index: 1000;
font-size: 12px;
color: #333;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(4px);
padding: 3px 8px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.map-search-results-line:empty { display: none; }
@media (max-width: 600px) {
.map-search-overlay input[type="text"] { width: 160px; }
}
@media (max-width: 400px) {
.map-search-overlay input[type="text"] { width: 120px; }
}
/* Display-only color legend at bottom-center of the map. The functional
source filter (toggle/show-hide) lives in #sourceFilter in the side
panel. aria-hidden="true" on this element keeps screen readers from
reading the palette twice. Sits above Cesium's bottom-left credits. */
.map-color-legend {
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
gap: 14px;
font-size: 12px;
color: #333;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(4px);
padding: 6px 12px;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: none; /* never intercept globe interaction */
white-space: nowrap;
}
.map-color-legend > span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.map-color-legend .map-color-legend-dot {
width: 9px;
height: 9px;
border-radius: 50%;
display: inline-block;
}
@media (max-width: 600px) {
/* Below 600px the four-source row gets cramped — let it wrap and
shrink the padding so it doesn't crowd the map. */
.map-color-legend {
flex-wrap: wrap;
gap: 6px 10px;
max-width: calc(100% - 24px);
justify-content: center;
font-size: 11px;
padding: 4px 8px;
}
}
.sidebar-search { display: flex; flex-direction: column; gap: 4px; }
.sidebar-search input {
width: 100%;
padding: 7px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.sidebar-search input:focus { border-color: #1565c0; box-shadow: 0 0 0 2px rgba(21,101,192,0.15); }
.sidebar-search-hint { font-size: 11px; color: #888; }
.side-panel {
display: flex;
flex-direction: column;
gap: 8px;
max-height: var(--explorer-map-height);
overflow-y: auto;
}
.panel-section {
background: #f8f9fa;
border-radius: 6px;
padding: 12px;
font-size: 13px;
}
.panel-section h4 { margin: 0 0 8px 0; font-size: 14px; }
.stats-compact {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.stat-box {
background: #1a1a2e;
color: white;
padding: 8px 10px;
border-radius: 4px;
text-align: center;
}
.stat-box .val { font-weight: bold; font-size: 16px; font-family: monospace; display: block; }
.stat-box .lbl { color: #aaa; font-size: 11px; }
.legend { display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; }
.legend-item { display: flex; align-items: center; gap: 3px; cursor: pointer; user-select: none; }
.legend-item.disabled { opacity: 0.3; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.legend-item input[type="checkbox"] { margin: 0; width: 12px; height: 12px; cursor: pointer; }
.source-badge { color: white; padding: 2px 8px; border-radius: 10px; font-size: 0.8em; white-space: nowrap; }
.cluster-card { border-left: 4px solid #ccc; padding: 10px 12px; background: white; border-radius: 0 6px 6px 0; }
.sample-row { padding: 6px 0; border-bottom: 1px solid #eee; line-height: 1.4; }
.sample-row:last-child { border-bottom: none; }
.sample-label { font-weight: 600; font-size: 13px; }
.sample-desc { font-size: 12px; color: #555; margin-top: 2px; }
.sample-meta { font-size: 11px; color: #888; margin-top: 2px; }
.phase-msg { padding: 6px 10px; border-radius: 4px; font-size: 12px; transition: all 0.3s ease; }
#clusterSection .empty-state { color: #999; text-align: center; padding: 20px; }
.detail-loading { text-align: center; color: #999; padding: 8px; font-size: 12px; }
.detail-link { color: #1565c0; text-decoration: none; font-size: 12px; }
.detail-link:hover { text-decoration: underline; }
.share-btn {
background: #1565c0; color: white; border: none;
padding: 10px 16px; border-radius: 6px;
font-size: 14px; font-weight: 500; cursor: pointer;
width: 100%;
}
.share-btn:hover { background: #0d47a1; }
.share-toast { display: block; font-size: 12px; color: #2e7d32; opacity: 0; transition: opacity 0.3s; margin-top: 4px; text-align: center; }
/* Cesium toolbar — relocate to top-left of globe (Hana mockup, #178) */
.cesium-viewer-toolbar {
top: 5px;
left: 5px;
right: auto !important;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.cesium-viewer-toolbar > * { margin-left: 0 !important; }
/* Open baseLayerPicker dropdown to the right since we're left-anchored.
z-index must beat .map-search-overlay (1000) so the dropdown is not
occluded by the in-map search controls. */
.cesium-baseLayerPicker-dropDown {
left: 36px;
right: auto;
z-index: 1100;
}
.search-bar { display: flex; gap: 6px; }
.search-bar input {
flex: 1; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;
font-size: 14px; outline: none;
}
.search-bar input:focus { border-color: #1565c0; box-shadow: 0 0 0 2px rgba(21,101,192,0.15); }
.search-actions {
display: flex; gap: 6px;
}
.search-actions button {
flex: 1; border: none; padding: 8px 12px; border-radius: 4px;
cursor: pointer; font-size: 13px; font-weight: 500; color: white;
white-space: nowrap;
}
.search-actions #searchAreaBtn { background: #ef6c00; }
.search-actions #searchAreaBtn:hover { background: #e65100; }
.search-actions #searchWorldBtn { background: #1565c0; }
.search-actions #searchWorldBtn:hover { background: #0d47a1; }
.search-help {
font-size: 11px;
color: #888;
line-height: 1.3;
}
.search-results {
font-size: 12px;
color: #666;
padding: 4px 0;
min-height: calc(2.7em + 8px);
max-height: calc(2.7em + 8px);
overflow-y: auto;
line-height: 1.35;
}
#tableContainer {
margin-bottom: 16px;
}
/* Stale-while-loading: dim the existing table to ~60% opacity while
a new page or filter result is being fetched, so the user can see
that the visible rows are not yet current. The CSS-animated spinner
in #tableMeta provides the explicit "working" affordance. */
#tableContainer.is-loading .samples-table {
opacity: 0.6;
}
.samples-table { transition: opacity 0.15s ease; }
.table-loading-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #cfd8e3;
border-top-color: #1565c0;
border-radius: 50%;
animation: tableSpin 0.8s linear infinite;
vertical-align: -2px;
margin-right: 6px;
}
@keyframes tableSpin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.table-loading-spinner { animation: none; border-top-color: #1565c0; }
}
.samples-section-heading {
font-size: 15px;
font-weight: 600;
color: #234;
margin: 18px 0 8px;
}
.samples-table tbody tr {
cursor: pointer;
}
.samples-table tbody tr:hover {
background: #f3f7fc;
}
.samples-table tbody tr.selected {
background: #d8e8fa;
}
.samples-table tbody tr.selected:hover {
background: #cfe1f7;
}
.table-meta {
font-size: 12px;
color: #555;
margin: 4px 0 8px;
}
.table-scroll {
overflow-x: auto;
border: 1px solid #d8dee6;
border-radius: 6px;
}
.samples-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: white;
}
.samples-table th, .samples-table td {
padding: 7px 9px;
border-bottom: 1px solid #edf0f4;
text-align: left;
vertical-align: top;
}
.samples-table th {
background: #f6f8fb;
color: #344;
font-weight: 600;
white-space: nowrap;
}
.samples-table tr:last-child td { border-bottom: 0; }
.table-badge {
color: white;
padding: 2px 7px;
border-radius: 10px;
font-size: 10px;
white-space: nowrap;
}
.table-link { color: #1565c0; text-decoration: none; }
.table-link:hover { text-decoration: underline; }
.table-pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: #555;
}
.table-pager button {
background: #1565c0;
color: white;
border: 0;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
}
.table-pager button:disabled {
background: #c7d2df;
cursor: default;
}
.filter-section { border-top: 1px solid #eee; padding-top: 8px; margin-top: 8px; }
.filter-header {
font-size: 12px; font-weight: 600; color: #555; cursor: pointer;
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0; user-select: none;
}
.filter-header:hover { color: #1565c0; }
.filter-body { padding: 4px 0; }
.filter-body label { display: block; font-size: 12px; padding: 2px 0; cursor: pointer; }
.filter-body label:hover { color: #1565c0; }
/* Cross-filter zero-count dimming. Don't hide unavailable values; keep
them visible so users can understand the current filter context. */
.facet-row.zero { opacity: 0.4; }
.facet-row.zero:hover { opacity: 0.65; }
.facet-count.recomputing { opacity: 0.55; font-style: italic; }
</style>
```
::: {.callout-note collapse="true"}
## How It Works
1. **Instant** (<1s): Pre-aggregated H3 res4 summary (580 KB) → 38K colored circles
2. **Zoom in**: Automatically switches to res6 (112K) then res8 (176K) clusters
3. **Zoom deeper** (<120 km): Individual sample points from 60 MB lite parquet
4. **Click**: Cluster info or individual sample card with full metadata
5. **Search**: Find samples by name — results fly to the location on the globe
Circle size = log(sample count). Color = dominant data source.
:::
<!-- Static layout: globe + side panel + permanent samples table. Updated via
DOM, not OJS reactivity. Wrapped in `{=html}` raw-html fence because
otherwise Quarto's Pandoc pass parses Markdown inside the HTML and wraps
bare `<input>` / `<label>` in `<p>` tags, breaking flex layouts and
adding paragraph margins to control rows. -->
```{=html}
<div class="globe-layout">
<div class="map-wrap">
<div id="cesiumContainer"></div>
<!-- Slim search overlay (Hana Figma 222:456). One-line input + magnifier
button at the top-right of the map. The two scope buttons and the
help paragraph are preserved in the DOM (display:none via CSS) so
existing zoomWatcher handlers bind by id and the `?search_scope=area`
URL hydration path still works — area-scoped search is no longer
surfaced in the in-map UI but remains reachable via the URL. -->
<div class="map-search-overlay">
<input type="text" id="sampleSearch"
placeholder="Search samples — e.g., pottery Cyprus"
title="Searches labels, descriptions, and place names. First search can take 10-15 seconds while data loads; subsequent searches are faster. Press Enter or click the search button to submit." />
<button id="searchSubmitBtn" type="button" aria-label="Search samples" title="Search (Enter)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="20" y1="20" x2="16.65" y2="16.65"></line></svg>
</button>
<!-- Preserved-but-hidden scope buttons: kept so existing zoomWatcher
`searchAreaBtn.addEventListener('click', ...)` / `searchWorldBtn`
bindings continue to compile and so any external code (tests,
bookmarklets) that clicks them programmatically still works.
`?search_scope=area` URL hydration does NOT depend on these — it
sets `_searchScope` directly and the boot auto-search reads it. -->
<div class="search-actions" aria-hidden="true">
<button id="searchAreaBtn" type="button" tabindex="-1">Search Selected Areas</button>
<button id="searchWorldBtn" type="button" tabindex="-1">Search Entire World</button>
</div>
<div class="search-help" aria-hidden="true"></div>
</div>
<div id="searchResults" class="map-search-results-line" aria-live="polite"></div>
<div class="map-color-legend" aria-hidden="true">
<span><span class="map-color-legend-dot" style="background:#3366CC"></span>SESAR</span>
<span><span class="map-color-legend-dot" style="background:#DC3912"></span>OpenContext</span>
<span><span class="map-color-legend-dot" style="background:#109618"></span>GEOME</span>
<span><span class="map-color-legend-dot" style="background:#FF9900"></span>Smithsonian</span>
</div>
</div>
<div class="side-panel">
<div class="panel-section sidebar-search">
<input type="text" id="sampleSearchSidebar" placeholder="Search samples (press Enter to search globally)" aria-label="Search samples globally" />
<div class="sidebar-search-hint">Enter searches the entire world. Use the in-map controls for area-limited search.</div>
</div>
<div class="panel-section">
<div class="stats-compact">
<div class="stat-box"><span id="sPhase" class="val">Loading...</span><span class="lbl">Resolution</span></div>
<div class="stat-box"><span id="sPoints" class="val">0</span><span id="sPointsLbl" class="lbl">Clusters Loaded</span></div>
<div class="stat-box"><span id="sSamples" class="val">0</span><span id="sSamplesLbl" class="lbl">Samples Loaded</span></div>
<div class="stat-box"><span id="sTime" class="val">-</span><span class="lbl">Load Time</span></div>
</div>
<div style="margin-top: 8px;">
<div class="legend" id="sourceFilter">
<label class="legend-item facet-row" data-facet="source" data-value="SESAR"><input type="checkbox" value="SESAR" checked><span class="legend-dot" style="background:#3366CC"></span> SESAR <span class="facet-count" data-facet="source" data-value="SESAR" style="color:#888"></span></label>
<label class="legend-item facet-row" data-facet="source" data-value="OPENCONTEXT"><input type="checkbox" value="OPENCONTEXT" checked><span class="legend-dot" style="background:#DC3912"></span> OpenContext <span class="facet-count" data-facet="source" data-value="OPENCONTEXT" style="color:#888"></span></label>
<label class="legend-item facet-row" data-facet="source" data-value="GEOME"><input type="checkbox" value="GEOME" checked><span class="legend-dot" style="background:#109618"></span> GEOME <span class="facet-count" data-facet="source" data-value="GEOME" style="color:#888"></span></label>
<label class="legend-item facet-row" data-facet="source" data-value="SMITHSONIAN"><input type="checkbox" value="SMITHSONIAN" checked><span class="legend-dot" style="background:#FF9900"></span> Smithsonian <span class="facet-count" data-facet="source" data-value="SMITHSONIAN" style="color:#888"></span></label>
</div>
</div>
<div class="filter-section" id="materialFilter">
<div class="filter-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
Material <span>▾</span>
</div>
<div class="filter-body" style="display: none;" id="materialFilterBody">
<em style="font-size: 11px; color: #999;">Loading...</em>
</div>
</div>
<div class="filter-section" id="contextFilter">
<div class="filter-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
Sampled Feature <span>▾</span>
</div>
<div class="filter-body" style="display: none;" id="contextFilterBody">
<em style="font-size: 11px; color: #999;">Loading...</em>
</div>
</div>
<div class="filter-section" id="objectTypeFilter">
<div class="filter-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
Specimen Type <span>▾</span>
</div>
<div class="filter-body" style="display: none;" id="objectTypeFilterBody">
<em style="font-size: 11px; color: #999;">Loading...</em>
</div>
</div>
<div id="facetNote" style="display: none; font-size: 11px; color: #888; margin-top: 4px; font-style: italic;">
Material / feature / specimen filters apply at sample zoom level — zoom in or click a cluster.
</div>
<div style="margin-top: 12px;">
<button id="shareBtn" class="share-btn" title="Copy link to current view">Copy Link to Current View</button>
<span id="shareToast" class="share-toast">Link copied!</span>
</div>
<div id="phaseMsg" class="phase-msg" style="margin-top: 8px; background: #e3f2fd; color: #1565c0;">
Loading H3 global overview...
</div>
</div>
<div id="clusterSection" class="panel-section">
<div class="empty-state">Click a cluster or sample on the globe</div>
</div>
<div id="samplesSection" class="panel-section" style="flex: 1; overflow-y: auto;"></div>
</div>
</div>
<h3 class="samples-section-heading">Samples table</h3>
<div id="tableContainer" aria-busy="false">
<div id="tableMeta" class="table-meta">Loading samples matching the current filters...</div>
<div id="samplesTable"></div>
<div class="table-pager">
<button id="tablePrev" type="button">Previous</button>
<span id="tablePageInfo"></span>
<button id="tableNext" type="button">Next</button>
</div>
</div>
```
```{ojs}
//| output: false
Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIwNzk3NjkyMy1iNGI1LTRkN2UtODRiMy04OTYwYWE0N2M3ZTkiLCJpZCI6Njk1MTcsImlhdCI6MTYzMzU0MTQ3N30.e70dpNzOCDRLDGxRguQCC-tRzGzA-23Xgno5lNgCeB4';
```
```{ojs}
//| echo: false
//| output: false
// === Constants ===
R2_BASE = "https://data.isamples.org"
h3_res4_url = `${R2_BASE}/isamples_202601_h3_summary_res4.parquet`
h3_res6_url = `${R2_BASE}/isamples_202601_h3_summary_res6.parquet`
h3_res8_url = `${R2_BASE}/isamples_202601_h3_summary_res8.parquet`
lite_url = `${R2_BASE}/isamples_202601_samples_map_lite.parquet`
// Stable alias that 302-redirects to the current enriched wide parquet
// (isamples_YYYYMM_wide.parquet). Gets OpenContext thumbnails populated.
wide_url = `${R2_BASE}/current/wide.parquet`
// v2 carries object_type alongside material and context (URI-string columns).
facets_url = `${R2_BASE}/isamples_202601_sample_facets_v2.parquet`
facet_summaries_url = `${R2_BASE}/isamples_202601_facet_summaries.parquet`
// Pre-aggregated single-filter cache for fast cross-filtered facet counts.
cross_filter_url = `${R2_BASE}/isamples_202601_facet_cross_filter.parquet`
// SKOS prefLabels for Material / Sampled Feature / Specimen Type URIs.
// ~60 KB lookup; falls back to URI tail if a URI isn't covered.
vocab_labels_url = `${R2_BASE}/vocab_labels.parquet`
// Canonical palette — see issue #113. Path-relative so this works under
// both isamples.org (custom domain at root) and project-pages fork
// previews (rdhyee.github.io/isamplesorg.github.io/...).
_palette = await import(new URL('assets/js/source-palette.js', document.baseURI).href)
SOURCE_COLORS = _palette.SOURCE_COLORS
SOURCE_NAMES = _palette.SOURCE_NAMES
// === Source URL: resolve pid to original repository ===
function sourceUrl(pid) {
if (!pid) return null;
// All sources resolve via n2t.net:
// ARK pids (OpenContext, GEOME, Smithsonian) → n2t.net/ark:/...
// IGSN pids (SESAR) → n2t.net/IGSN:...
return `https://n2t.net/${pid}`;
}
// === Source Filter: get active sources and build SQL clause ===
function getActiveSources() {
const checks = document.querySelectorAll('#sourceFilter input[type="checkbox"]');
return Array.from(checks).filter(c => c.checked).map(c => c.value);
}
function sourceFilterSQL(col) {
const active = getActiveSources();
if (active.length === 0) return ' AND 1=0'; // nothing checked = show nothing
if (active.length === 4) return ''; // all checked = no filter
const list = active.map(s => `'${s}'`).join(',');
return ` AND ${col} IN (${list})`;
}
SOURCE_VALUES = ['SESAR', 'OPENCONTEXT', 'GEOME', 'SMITHSONIAN']
DEFAULT_POINT_BUDGET = 5000
// Disable per-primitive depth test below this camera distance (meters).
// All cluster and sample point primitives are placed at altitude=0 (ellipsoid
// surface). With Cesium world terrain enabled, terrain mesh in hilly regions
// (e.g. Troodos mountains in Cyprus, Birmingham AL hills) rises hundreds to
// thousands of meters above ellipsoid — making sea-level primitives
// physically underground and depth-culled by the standard per-pixel depth
// test (issue #185).
//
// Bypass scope: only when the camera is closer than 2,000 km, which covers
// every realistic interactive altitude (point mode <120 km, res8 cluster
// mode up to ~300 km, res6 up to ~3,000 km — the upper range is technically
// outside the bypass but at that altitude dots are tiny anyway, so this is
// where the existing pre-issue behavior is preserved).
//
// Why bounded (2.0e6) and not POSITIVE_INFINITY: PR #181 used Infinity and
// caused back-side-of-globe primitive bleed-through (the depth test was the
// only thing keeping points on the FAR hemisphere from rendering through
// the Earth). A 2,000 km bound preserves globe-ellipsoid occlusion at
// long range while bypassing terrain occlusion at every interactive zoom.
POINT_DEPTH_TEST_DISTANCE = 2.0e6
function csvParamValues(params, key) {
if (!params.has(key)) return null;
const raw = params.get(key) || '';
if (raw.trim() === '') return [];
return raw.split(',').map(s => s.trim()).filter(Boolean);
}
function updateSourceLegendState() {
document.querySelectorAll('#sourceFilter .legend-item').forEach(li => {
const cb = li.querySelector('input');
li.classList.toggle('disabled', !cb.checked);
});
}
function applyQueryToSourceFilter() {
const params = new URLSearchParams(location.search);
const initialSources = csvParamValues(params, 'sources');
if (initialSources == null) return;
const allowed = new Set(SOURCE_VALUES);
const selected = new Set(initialSources.filter(s => allowed.has(s)));
document.querySelectorAll('#sourceFilter input[type="checkbox"]').forEach(cb => {
cb.checked = selected.has(cb.value);
});
updateSourceLegendState();
}
// URL param is `search`, not `q` — Quarto's site-wide search hijacks `?q=`
// (highlights matches and strips the param via replaceState).
// See docs/site_libs/quarto-search/quarto-search.js.
function applyQueryToSearch() {
const input = document.getElementById('sampleSearch');
const sidebarInput = document.getElementById('sampleSearchSidebar');
if (!input && !sidebarInput) return;
const params = new URLSearchParams(location.search);
const q = params.get('search');
if (q != null) {
if (input) input.value = q;
if (sidebarInput) sidebarInput.value = q;
}
}
function setCheckedValues(containerId, values) {
if (values == null) return;
const selected = new Set(values);
document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => {
cb.checked = selected.has(cb.value);
});
}
function applyQueryToFacetFilters() {
const params = new URLSearchParams(location.search);
setCheckedValues('materialFilterBody', csvParamValues(params, 'material'));
setCheckedValues('contextFilterBody', csvParamValues(params, 'context'));
setCheckedValues('objectTypeFilterBody', csvParamValues(params, 'object_type'));
}
function writeQueryState() {
const params = new URLSearchParams(location.search);
const searchInput = document.getElementById('sampleSearch');
const q = searchInput ? searchInput.value.trim() : '';
if (q) params.set('search', q);
else params.delete('search');
const activeSources = getActiveSources();
if (activeSources.length === SOURCE_VALUES.length) params.delete('sources');
else params.set('sources', activeSources.join(','));
[
['material', 'materialFilterBody'],
['context', 'contextFilterBody'],
['object_type', 'objectTypeFilterBody'],
].forEach(([key, containerId]) => {
const values = getCheckedValues(containerId);
if (values.length > 0) params.set(key, values.join(','));
else params.delete(key);
});
// Canonicalize away the legacy `view` param (issue #200 / M-5 removed
// the Globe/Table toggle). A bookmarked `?view=table&...` URL would
// otherwise stick through filter/share flows.
params.delete('view');
const qs = params.toString();
const url = `${location.pathname}${qs ? `?${qs}` : ''}${location.hash}`;
if (url !== `${location.pathname}${location.search}${location.hash}`) {
history.replaceState(null, '', url);
}
}
function searchTerms(value) {
return String(value || '').trim().split(/\s+/).filter(Boolean);
}
function escapeIlikePattern(value) {
return escSql(value).replace(/[\\%_]/g, "\\$&");
}
function textSearchWhere(terms, columns) {
return terms.map(raw => {
const term = escapeIlikePattern(raw);
const checks = columns.map(col => `${col} ILIKE '%${term}%' ESCAPE '\\'`);
return `(${checks.join(' OR ')})`;
}).join(' AND ');
}
function textSearchScore(terms, weightedColumns) {
if (!terms.length) return '0';
return terms.map(raw => {
const term = escapeIlikePattern(raw);
return weightedColumns.map(({ col, weight }) =>
`CASE WHEN ${col} ILIKE '%${term}%' ESCAPE '\\' THEN ${weight} ELSE 0 END`
).join(' + ');
}).map(score => `(${score})`).join(' + ');
}
// === Material / Sampled Feature / Specimen Type Filters ===
// Checkbox semantics: start UNCHECKED (no filter; show everything). User
// checks items to *include only those*. Empty = no filter. Matches the
// explorer's URI-valued facet UX — with hundreds of materials, defaulting
// to "all checked" would be unusable, and "empty = no filter" is the
// natural reading. See issue #155.
function getCheckedValues(containerId) {
const checks = document.querySelectorAll(`#${containerId} input[type="checkbox"]`);
return Array.from(checks).filter(c => c.checked).map(c => c.value);
}
function hasFacetFilters() {
return getCheckedValues('materialFilterBody').length > 0
|| getCheckedValues('contextFilterBody').length > 0
|| getCheckedValues('objectTypeFilterBody').length > 0;
}
function escSql(value) {
return String(value).replace(/'/g, "''");
}
// Returns a portable predicate fragment (no outer-table alias dependency)
// that callers append to a WHERE: ` AND ${facetFilterSQL()}`. Uses a
// `pid IN (SELECT pid FROM facets WHERE ...)` subquery so it works
// without a JOIN and avoids duplicate rows from multi-valued facets
// (a sample with two materials would appear twice via JOIN). Required
// for Phase 4's table mode and any non-JOIN caller. See issue #156.
function facetFilterSQL() {
const mat = getCheckedValues('materialFilterBody');
const ctx = getCheckedValues('contextFilterBody');
const ot = getCheckedValues('objectTypeFilterBody');
const conds = [];
if (mat.length > 0) {
const list = mat.map(s => `'${escSql(s)}'`).join(',');
conds.push(`material IN (${list})`);
}
if (ctx.length > 0) {
const list = ctx.map(s => `'${escSql(s)}'`).join(',');
conds.push(`context IN (${list})`);
}
if (ot.length > 0) {
const list = ot.map(s => `'${escSql(s)}'`).join(',');
conds.push(`object_type IN (${list})`);
}
if (conds.length === 0) return '';
return ` AND pid IN (SELECT DISTINCT pid FROM read_parquet('${facets_url}') WHERE ${conds.join(' AND ')})`;
}
// Shared viewport-padding factor. The samples table (PR #219), the
// point-mode sample loader, and the cluster-mode "Samples in View"
// stat (issue #221 round 2) all expand the raw view rectangle by this
// factor so the three surfaces agree on a single "in view" predicate.
// Pass 0 (or undefined) for exact-viewport semantics (area search).
// OJS cell syntax (bare `name = value`); `const` here would not bind
// the name into the OJS module scope and would break every dependent
// cell (issue #223 quick-fix).
VIEWPORT_PAD_FACTOR = 0.3
// Return the viewer's current view rectangle, optionally padded in each
// direction by `padFactor × span`, normalized into [-180, 180] longitude
// (wrapping the antimeridian when needed). Returns null when the camera
// can't produce a rectangle (off-globe; rare).
//
// Dateline crossing: when the returned `west > east` the rectangle
// wraps the antimeridian; callers that filter sample longitudes must
// split on that boundary.
//
// Normalization after padding:
// - Total span >= 360 → padding consumed the globe; return west=-180,
// east=180 (no wrap).
// - west < -180 or east > 180 → rotate the endpoint by 360 so it
// lands in [-180, 180]. A non-wrapping rect like west=170, east=179
// padded 30% becomes (167.3, 181.7); east wraps to -178.3 and the
// `west > east` flag flips on.
function paddedViewportBounds(padFactor) {
if (typeof viewer === 'undefined') return null;
const rect = viewer.camera.computeViewRectangle(viewer.scene.globe.ellipsoid);
if (!rect) return null;
let south = Cesium.Math.toDegrees(rect.south);
let north = Cesium.Math.toDegrees(rect.north);
let west = Cesium.Math.toDegrees(rect.west);
let east = Cesium.Math.toDegrees(rect.east);
if (padFactor && padFactor > 0) {
const latPad = (north - south) * padFactor;
// Original longitude span. Wraps the antimeridian iff west > east.
const originalSpan = (west > east) ? (180 - west) + (east + 180) : east - west;
const lngPad = originalSpan * padFactor;
south -= latPad;
north += latPad;
west -= lngPad;
east += lngPad;
if (south < -90) south = -90;
if (north > 90) north = 90;
const totalSpan = originalSpan + 2 * lngPad;
if (totalSpan >= 360) {
west = -180; east = 180;
} else {
if (west < -180) west += 360;
if (east > 180) east -= 360;
}
}
return { south, north, west, east };
}
// Build a viewport-bbox SQL predicate for the given lat/lng column names.
// Returns null when the camera can't produce a rectangle (off-globe; rare).
// Callers must decide what to do with null — fall back to world (search)
// vs. show empty state (table). NEVER fall back to no-bbox-filter for
// surfaces that are labeled "in view" — that recreates the bug shape
// PR #219 was filed for.
//
// Used by:
// - tableView (PR #219): scopes the samples table to the current viewport
// so "samples in view" in cluster/point mode == table row count.
// - doSearch('area') (#178 light path): exact-viewport text search.
function viewerBboxSQL(latCol, lngCol, padFactor) {
const b = paddedViewportBounds(padFactor);
if (!b) return null;
const { south, north, west, east } = b;
const lngClause = (west > east)
? `(${lngCol} BETWEEN ${west} AND 180 OR ${lngCol} BETWEEN -180 AND ${east})`
: `${lngCol} BETWEEN ${west} AND ${east}`;
return ` AND ${latCol} BETWEEN ${south} AND ${north} AND ${lngClause}`;
}
// === Cross-filter facet count UI helpers ===
function applyFacetCounts(facetKey, countsMap) {
const baseline = (viewer && viewer._baselineCounts) ? viewer._baselineCounts[facetKey] : null;
document.querySelectorAll(`.facet-count[data-facet="${facetKey}"]`).forEach(el => {
const value = el.getAttribute('data-value');
let count;
if (countsMap) {
count = countsMap.has(value) ? countsMap.get(value) : 0;
} else {
count = baseline ? (baseline.get(value) ?? 0) : 0;
}
el.textContent = `(${Number(count).toLocaleString()})`;
el.classList.remove('recomputing');
const row = document.querySelector(`.facet-row[data-facet="${facetKey}"][data-value="${CSS.escape(value)}"]`);
if (row) row.classList.toggle('zero', count === 0);
});
}
function markFacetCountsRecomputing() {
document.querySelectorAll('.facet-count').forEach(el => el.classList.add('recomputing'));
}
// === URL State: encode/decode globe state in hash fragment ===
function parseNum(val, def, min, max) {
if (val == null) return def;
const n = parseFloat(val);
if (!Number.isFinite(n)) return def;
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
}
function readHash() {
const params = new URLSearchParams(location.hash.slice(1));
return {
v: parseInt(params.get('v')) || 0,
lat: parseNum(params.get('lat'), null, -90, 90),
lng: parseNum(params.get('lng'), null, -180, 180),
alt: parseNum(params.get('alt'), null, 100, 40000000),
heading: parseNum(params.get('heading'), 0, 0, 360),
pitch: parseNum(params.get('pitch'), -90, -90, 0),
mode: params.get('mode') || null,
pid: params.get('pid') || null,
h3: params.get('h3') || null,
};
}
function buildHash(v) {
const cam = v.camera;
const carto = cam.positionCartographic;
const params = new URLSearchParams();
params.set('v', '1');
params.set('lat', Cesium.Math.toDegrees(carto.latitude).toFixed(4));
params.set('lng', Cesium.Math.toDegrees(carto.longitude).toFixed(4));
params.set('alt', Math.round(carto.height).toString());
const heading = Cesium.Math.toDegrees(cam.heading) % 360;
const pitch = Cesium.Math.toDegrees(cam.pitch);
if (Math.abs(heading) > 1) params.set('heading', heading.toFixed(1));
if (Math.abs(pitch + 90) > 1) params.set('pitch', pitch.toFixed(1));
const gs = v._globeState;
if (gs.mode === 'point') params.set('mode', 'point');
// pid and h3 are mutually exclusive at runtime; emit at most one. Sample
// selection (pid) wins if somehow both are set — matches the hydration
// priority in the hashchange handler.
if (gs.selectedPid) params.set('pid', gs.selectedPid);
else if (gs.selectedH3) params.set('h3', gs.selectedH3);
return '#' + params.toString();
}
// === Selection freshness primitive ===
//
// Async work that updates `viewer._globeState`, the URL hash, or the side
// panel must check freshness after every await. A user input (click,
// hashchange, source-filter toggle, boot deep-link) bumps `_selGen`; any
// in-flight async work whose generation no longer matches must NOT mutate
// anything that an interactive newer event has already moved.
//
// Usage:
// const isStale = freshSelectionToken(viewer);
// await someWork();
// if (isStale()) return;
// updateDOM();
//
// Pass `isStale` into helpers (see hydrateClusterUI) so their internal
// awaits also bail before touching DOM. Top-level so both the viewer-cell
// click handler and the zoomWatcher-cell handlers can reach it. See issue
// #187 for the post-mortem that motivated extracting this primitive.
function freshSelectionToken(v) {
v._selGen = (v._selGen || 0) + 1;
const gen = v._selGen;
return () => gen !== v._selGen;
}
// === Helpers: update DOM imperatively (no OJS reactivity) ===
function updateStats(phase, points, samples, time, pointsLabel, samplesLabel) {
const s = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
s('sPhase', phase);
s('sPoints', typeof points === 'string' ? points : points.toLocaleString());
s('sSamples', typeof samples === 'string' ? samples : samples.toLocaleString());
if (time != null) s('sTime', time);
if (pointsLabel) s('sPointsLbl', pointsLabel);
if (samplesLabel) s('sSamplesLbl', samplesLabel);
}
function updatePhaseMsg(text, type) {
const m = document.getElementById('phaseMsg');
if (!m) return;
m.textContent = text;
if (type === 'loading') { m.style.background = '#e3f2fd'; m.style.color = '#1565c0'; }