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
62 changes: 52 additions & 10 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 @@ -263,18 +303,19 @@ Circle size = log(sample count). Color = dominant data source.
:::

<!-- Static layout: globe + side panel. Updated via DOM, not OJS reactivity. -->
<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,6 +327,7 @@ 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>

<div class="globe-layout">
<div id="cesiumContainer"></div>
Expand Down
132 changes: 132 additions & 0 deletions tests/playwright/explorer-layout-stability.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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,');
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');
await waitForPhaseMessage(page, 'individual samples', 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 }) => {
await page.setViewportSize({ width: 390, height: 844 });
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(Math.abs(initialRect.height - 489.52)).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);
});
});
Loading