Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 61 additions & 11 deletions explorer.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,23 @@ format:
<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 {
--explorer-map-height: clamp(500px, 65vh, 680px);
--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;
Expand All @@ -30,23 +44,28 @@ format:
}
.globe-layout {
display: grid;
grid-template-columns: 1fr 340px;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 12px;
margin-bottom: 16px;
}
@media (max-width: 900px) {
:root {
--explorer-map-height: clamp(360px, 58vh, 520px);
--explorer-shell-width: calc(100vw - 32px);
}
.globe-layout { grid-template-columns: 1fr; }
}
#cesiumContainer {
width: 100%;
min-height: 500px;
aspect-ratio: 4/3;
height: var(--explorer-map-height);
min-height: 0;
aspect-ratio: auto;
}
.side-panel {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 700px;
max-height: var(--explorer-map-height);
overflow-y: auto;
}
.panel-section {
Expand Down Expand Up @@ -111,14 +130,20 @@ format:
left: 36px;
right: auto;
}
.search-bar { display: flex; gap: 6px; margin-bottom: 6px; }
.explorer-controls {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.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; margin-bottom: 8px;
display: flex; gap: 6px;
}
.search-actions button {
flex: 1; border: none; padding: 8px 12px; border-radius: 4px;
Expand All @@ -129,14 +154,28 @@ format:
.search-actions #searchAreaBtn:hover { background: #e65100; }
.search-actions #searchWorldBtn { background: #1565c0; }
.search-actions #searchWorldBtn:hover { background: #0d47a1; }
.search-results { font-size: 12px; color: #666; padding: 4px 0; }
.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;
}
.view-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin: 10px 0 12px;
margin: 4px 0 0;
flex-wrap: wrap;
min-height: 50px;
}
.view-toggle {
display: inline-flex;
Expand Down Expand Up @@ -249,6 +288,7 @@ format:
.facet-row.zero:hover { opacity: 0.65; }
.facet-count.recomputing { opacity: 0.55; font-style: italic; }
</style>
```

::: {.callout-note collapse="true"}
## How It Works
Expand All @@ -262,19 +302,25 @@ format:
Circle size = log(sample count). Color = dominant data source.
:::

<!-- Static layout: globe + side panel. Updated via DOM, not OJS reactivity. -->
<!-- Static layout: globe + side panel. 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 the `.search-bar input { flex: 1 }` flex relationship
and adding paragraph margins to control rows. -->
```{=html}
<div class="explorer-controls">
<div class="search-bar">
<input type="text" id="sampleSearch" placeholder="Search samples — multiple words narrow results (e.g., pottery Cyprus)" />
</div>
<div class="search-actions">
<button id="searchAreaBtn" type="button" title="Limit results to samples within the current map view">Search Selected Areas</button>
<button id="searchWorldBtn" type="button" title="Search all samples globally">Search Entire World</button>
</div>
<div class="search-help" style="font-size: 11px; color: #888; padding: 2px 0 6px 0; line-height: 1.3;">
<div class="search-help">
Searches labels, descriptions, and place names. <strong>First search can take 10-15 seconds</strong> while data loads; subsequent searches are faster.
<a href="https://github.com/isamplesorg/isamplesorg.github.io/issues/169" target="_blank" rel="noopener noreferrer" style="color: #888; text-decoration: underline;">Tracking issue: faster substrate FTS in progress</a>.
</div>
<div id="searchResults" class="search-results"></div>
<div id="searchResults" class="search-results" aria-live="polite"></div>

<div class="view-toolbar">
<div class="view-toggle" role="group" aria-label="View mode">
Expand All @@ -286,7 +332,10 @@ Searches labels, descriptions, and place names. <strong>First search can take 10
<input type="number" id="maxSamples" min="1000" max="100000" step="1000" value="25000">
</div>
</div>
</div>
```

```{=html}
<div class="globe-layout">
<div id="cesiumContainer"></div>
<div class="side-panel">
Expand Down Expand Up @@ -356,6 +405,7 @@ Loading H3 global overview...
<button id="tableNext" type="button">Next</button>
</div>
</div>
```

```{ojs}
//| output: false
Expand Down
184 changes: 184 additions & 0 deletions tests/playwright/explorer-layout-stability.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
const { test, expect } = require('@playwright/test');

const BASE_URL = process.env.TEST_URL || 'http://localhost:5860';
const EXPLORER_PATH = '/explorer.html';

const ALT_WORLD = 10000000;
const ALT_POINT_CYPRUS = 62054;
const LAT_CYPRUS = 34.9954;
const LNG_CYPRUS = 33.7052;

async function waitForPhaseMessage(page, substring, timeoutMs = 60000) {
return await page.waitForFunction(
(sub) => {
const el = document.getElementById('phaseMsg');
const text = el ? el.textContent : '';
return text.includes(sub) ? text.trim() : null;
},
substring,
{ timeout: timeoutMs }
).then(handle => handle.jsonValue());
}

async function waitForClusterBoot(page) {
await waitForPhaseMessage(page, 'clusters,');
// Awaiting `value('zoomWatcher')` ensures the OJS cell has finished
// running — listener registration + boot hydration are complete by the
// time this resolves. The cell returns the string "active" so we don't
// use the return value, only the await.
await page.evaluate(async () => {
return await window._ojs.ojsConnector.mainModule.value('zoomWatcher');
});
}

async function waitForMode(page, expected, timeoutMs = 120000) {
await page.waitForFunction(
async (expectedMode) => {
const v = await window._ojs.ojsConnector.mainModule.value('viewer');
return v && v._globeState && v._globeState.mode === expectedMode;
},
expected,
{ timeout: timeoutMs }
);
}

async function flyCameraTo(page, lat, lng, alt) {
await page.evaluate(async ({ lat, lng, alt }) => {
const v = await window._ojs.ojsConnector.mainModule.value('viewer');
v.scene.requestRenderMode = false;
v.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lng, lat, alt),
duration: 1.0,
});
}, { lat, lng, alt });
}

async function settleLayout(page) {
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}

async function elementRect(page, selector) {
await settleLayout(page);
return await page.locator(selector).evaluate((el) => {
const r = el.getBoundingClientRect();
return {
top: r.top,
width: r.width,
height: r.height,
};
});
}

function expectRectStable(actual, expected, tolerance = 2) {
expect(Math.abs(actual.top - expected.top)).toBeLessThanOrEqual(tolerance);
expect(Math.abs(actual.width - expected.width)).toBeLessThanOrEqual(tolerance);
expect(Math.abs(actual.height - expected.height)).toBeLessThanOrEqual(tolerance);
}

test.describe('explorer layout stability', () => {
test('desktop globe rect is stable across boot, status, point mode, and table round trip', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
await page.waitForSelector('#cesiumContainer', { timeout: 30000 });

const initialRect = await elementRect(page, '#cesiumContainer');
expect(initialRect.width).toBeGreaterThanOrEqual(840);
expect(Math.abs(initialRect.height - 585)).toBeLessThanOrEqual(2);

await waitForClusterBoot(page);
expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect);

await page.locator('#searchResults').evaluate((el) => {
el.textContent = '50+ results for a deliberately long search status that wraps across two reserved lines';
});
expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect);

await flyCameraTo(page, LAT_CYPRUS, LNG_CYPRUS, ALT_POINT_CYPRUS);
await waitForMode(page, 'point');
// Wait on the trailing phrase common to BOTH point-mode done branches
// (normal: "<N> individual samples. Click one for details." and cap-reached:
// "<N> samples in view (showing M — zoom in for more). Click one for details.")
// rather than "individual samples", which misses the cap-reached path.
await waitForPhaseMessage(page, 'Click one for details', 120000);
expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect);

await page.locator('#tableViewBtn').click();
await expect(page.locator('.globe-layout')).toBeHidden();
await expect(page.locator('#tableContainer')).toBeVisible();
const tableRect = await elementRect(page, '#tableContainer');
expect(Math.abs(tableRect.top - initialRect.top)).toBeLessThanOrEqual(2);

await page.locator('#globeViewBtn').click();
await expect(page.locator('.globe-layout')).toBeVisible();
expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect);
});

test('mobile globe height override is stable across boot and wrapped status', async ({ page }) => {
const viewport = { width: 390, height: 844 };
await page.setViewportSize(viewport);
await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
await page.waitForSelector('#cesiumContainer', { timeout: 30000 });

const initialRect = await elementRect(page, '#cesiumContainer');
// Resolve the `clamp(360px, 58vh, 520px)` mobile value via a probe element
// styled with `height: var(--explorer-map-height)`. Reading the custom
// property directly via `getPropertyValue` returns the unresolved `clamp(...)`
// string, not a computed px value — so use a probe instead. This keeps the
// test honest if the clamp values change in CSS.
const expectedMapHeight = await page.evaluate(() => {
const probe = document.createElement('div');
probe.style.cssText = 'height: var(--explorer-map-height); position: absolute; visibility: hidden;';
document.body.appendChild(probe);
const h = probe.getBoundingClientRect().height;
probe.remove();
return h;
});
// Sanity: at 390×844, 58vh = 489.52, within [360, 520] clamp → 489.52.
expect(expectedMapHeight).toBeGreaterThanOrEqual(360);
expect(expectedMapHeight).toBeLessThanOrEqual(520);
expect(Math.abs(initialRect.height - expectedMapHeight)).toBeLessThanOrEqual(2);

await waitForClusterBoot(page);
expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect);

await page.locator('#searchResults').evaluate((el) => {
el.textContent = 'Search error: a long mobile status message that should scroll inside its reserved slot';
});
expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect);
});

test('small-phone (320×568) clamps map height to the 360px floor', async ({ page }) => {
// At 320×568, mobile CSS resolves `clamp(360px, 58vh, 520px)` with
// 58vh = 329.44px — below the 360px floor — so map height = 360px.
// Covers the clamp-floor branch which the 390×844 case never exercises.
await page.setViewportSize({ width: 320, height: 568 });
await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
await page.waitForSelector('#cesiumContainer', { timeout: 30000 });

const initialRect = await elementRect(page, '#cesiumContainer');
const expectedMapHeight = await page.evaluate(() => {
const probe = document.createElement('div');
probe.style.cssText = 'height: var(--explorer-map-height); position: absolute; visibility: hidden;';
document.body.appendChild(probe);
const h = probe.getBoundingClientRect().height;
probe.remove();
return h;
});
expect(expectedMapHeight).toBe(360);
expect(Math.abs(initialRect.height - 360)).toBeLessThanOrEqual(2);

await waitForClusterBoot(page);
expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect);
});
});
Loading