Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/workflows/explorer-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ jobs:
- name: Install node deps
run: npm install

# Fast pure-logic gate (#249 PR3): unit-tests the ES modules extracted
# from explorer.qmd (assets/js/sql-builders.js + explorer-utils.js).
# Node built-ins only, sub-second — runs before the slow Quarto/
# Playwright steps so a module regression fails fast.
- name: Unit tests (extracted modules)
run: node --test tests/unit/*.test.mjs

- name: Resolve Playwright version
id: pw
run: echo "version=$(node -e 'console.log(require("@playwright/test/package.json").version)')" >> "$GITHUB_OUTPUT"
Expand Down
2 changes: 2 additions & 0 deletions _quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ project:
output-dir: docs
resources:
- assets/js/source-palette.js
- assets/js/sql-builders.js
- assets/js/explorer-utils.js

website:
title: "iSamples"
Expand Down
66 changes: 66 additions & 0 deletions assets/js/explorer-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Pure helpers extracted from explorer.qmd (issue #249, PR3).
// No closure over `viewer`/`db`/DOM — safe to unit-test under Node and to
// import into the Interactive Explorer's OJS runtime (see explorer.qmd).

// HTML-escape a value for safe interpolation into innerHTML.
export function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

// Split a free-text query into whitespace-delimited terms (no empties).
export function searchTerms(value) {
return String(value || '').trim().split(/\s+/).filter(Boolean);
}

// Parse a numeric URL param with a default and optional clamping.
export 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;
}

// Read a comma-separated URLSearchParams value into an array of trimmed,
// non-empty strings. Returns null when the key is absent, [] when present
// but empty.
export 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);
}

// Resolve a pid to its canonical resolver URL. All iSamples sources resolve
// via n2t.net: ARK pids (OpenContext, GEOME, Smithsonian) and IGSN pids
// (SESAR) alike.
export function sourceUrl(pid) {
if (!pid) return null;
return `https://n2t.net/${pid}`;
}

// Decode the explorer globe state from a URL hash fragment.
// `hashStr` defaults to `location.hash` for in-browser callers (every current
// call site is zero-arg); tests pass an explicit string so `location` is never
// referenced. Numeric fields are clamped to valid geographic / altitude ranges.
export function readHash(hashStr = location.hash) {
const params = new URLSearchParams(hashStr.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,
heatmap: params.get('heatmap') === '1',
};
}
41 changes: 41 additions & 0 deletions assets/js/sql-builders.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Pure SQL-string builders extracted from explorer.qmd (issue #249, PR3).
// No closure over `viewer`/`db`/DOM — safe to unit-test under Node and to
// import into the Interactive Explorer's OJS runtime (see explorer.qmd).
//
// Internal dependency chain: textSearch* -> escapeIlikePattern -> escSql.
// Each function references the module-local sibling directly, so importing
// these into OJS does NOT create reactive edges between the bound cells.

// Double single-quotes for safe interpolation into a SQL string literal.
export function escSql(value) {
return String(value).replace(/'/g, "''");
}

// Escape a value for use inside an ILIKE '%...%' pattern with ESCAPE '\'.
// First SQL-escapes single quotes (escSql), then backslash-escapes the LIKE
// metacharacters \ % _ so they match literally.
export function escapeIlikePattern(value) {
return escSql(value).replace(/[\\%_]/g, "\\$&");
}

// Build a WHERE fragment: every term must match at least one column
// (terms AND-ed, columns OR-ed within a term).
export 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 ');
}

// Build a relevance-score expression: sum of per-term, per-weighted-column
// CASE contributions. Returns '0' when there are no terms.
export 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(' + ');
}
99 changes: 20 additions & 79 deletions explorer.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -774,14 +774,25 @@ _palette = await import(new URL('assets/js/source-palette.js', document.baseURI)
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}`;
}
// === Pure logic extracted to ES modules (issue #249, PR3) ===
// Same path-relative dynamic-import pattern as the palette above, so these
// resolve under both the custom domain (isamples.org) and fork project-pages
// previews. The functions are closure-free (no viewer/db/DOM) and unit-tested
// under Node (tests/unit/). Each `name = _module.fn` line is a single OJS cell
// that REPLACES the former inline `function name(){...}` cell.
_sqlBuilders = await import(new URL('assets/js/sql-builders.js', document.baseURI).href)
escSql = _sqlBuilders.escSql
escapeIlikePattern = _sqlBuilders.escapeIlikePattern
textSearchWhere = _sqlBuilders.textSearchWhere
textSearchScore = _sqlBuilders.textSearchScore

_explorerUtils = await import(new URL('assets/js/explorer-utils.js', document.baseURI).href)
escapeHtml = _explorerUtils.escapeHtml
searchTerms = _explorerUtils.searchTerms
parseNum = _explorerUtils.parseNum
csvParamValues = _explorerUtils.csvParamValues
sourceUrl = _explorerUtils.sourceUrl
readHash = _explorerUtils.readHash

// === Source Filter: get active sources and build SQL clause ===
function getActiveSources() {
Expand Down Expand Up @@ -821,13 +832,6 @@ DEFAULT_POINT_BUDGET = 5000
// 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');
Expand Down Expand Up @@ -940,32 +944,6 @@ function writeQueryState(opts = {}) {
}
}

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
Expand Down Expand Up @@ -1009,10 +987,6 @@ function syncFacetNote() {
el.style.display = (active && inCluster && !heatOn) ? 'block' : 'none';
}

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
Expand Down Expand Up @@ -1186,31 +1160,7 @@ function markFacetCountsRecomputing() {
}

// === 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,
heatmap: params.get('heatmap') === '1',
};
}

// (decode side — parseNum + readHash — extracted to assets/js/explorer-utils.js, PR3)
function buildHash(v) {
const cam = v.camera;
const carto = cam.positionCartographic;
Expand Down Expand Up @@ -1518,15 +1468,6 @@ function updateSamples(samples) {
// ORDER BY pid makes "Page N is the same N rows" actually true.
TABLE_PAGE_SIZE = 100

function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

```

```{ojs}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"private": true,
"scripts": {
"test": "playwright test",
"test:unit": "node --test tests/unit/*.test.mjs",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"test:debug": "playwright test --debug",
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/explorer-utils.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Unit tests for assets/js/explorer-utils.js (issue #249, PR3).
// Run: node --test tests/unit/ (Node built-ins only, no install)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
escapeHtml, searchTerms, parseNum, csvParamValues, sourceUrl, readHash,
} from '../../assets/js/explorer-utils.js';

test('escapeHtml escapes the five HTML-significant chars; nullish -> ""', () => {
assert.equal(
escapeHtml(`<a href="x">O'B & C</a>`),
'&lt;a href=&quot;x&quot;&gt;O&#39;B &amp; C&lt;/a&gt;'
);
assert.equal(escapeHtml(null), '');
assert.equal(escapeHtml(undefined), '');
assert.equal(escapeHtml(0), '0');
});

test('searchTerms splits on whitespace, drops empties', () => {
assert.deepEqual(searchTerms(' hello world '), ['hello', 'world']);
assert.deepEqual(searchTerms(''), []);
assert.deepEqual(searchTerms(null), []);
assert.deepEqual(searchTerms('one'), ['one']);
});

test('parseNum: default for nullish/non-finite, clamps to [min,max]', () => {
assert.equal(parseNum(null, 5), 5);
assert.equal(parseNum(undefined, 7), 7);
assert.equal(parseNum('abc', 5), 5);
assert.equal(parseNum('45', 0, 0, 90), 45);
assert.equal(parseNum('100', 0, 0, 90), 90); // clamp max
assert.equal(parseNum('-5', 0, 0, 90), 0); // clamp min
assert.equal(parseNum('3.5', 0), 3.5);
});

test('csvParamValues: null when absent, [] when empty, trimmed non-empty list', () => {
assert.equal(csvParamValues(new URLSearchParams(''), 'x'), null);
assert.deepEqual(csvParamValues(new URLSearchParams('x='), 'x'), []);
assert.deepEqual(csvParamValues(new URLSearchParams('x=a,b, c ,'), 'x'), ['a', 'b', 'c']);
});

test('sourceUrl: n2t.net resolver; null for falsy', () => {
assert.equal(sourceUrl('ark:/28722/k2x'), 'https://n2t.net/ark:/28722/k2x');
assert.equal(sourceUrl('IGSN:ABC123'), 'https://n2t.net/IGSN:ABC123');
assert.equal(sourceUrl(''), null);
assert.equal(sourceUrl(null), null);
});

test('readHash: full round-trip parse', () => {
assert.deepEqual(
readHash('#v=1&lat=10&lng=20&alt=500&heading=45&pitch=-30&mode=point&pid=abc&h3=8a&heatmap=1'),
{ v: 1, lat: 10, lng: 20, alt: 500, heading: 45, pitch: -30, mode: 'point', pid: 'abc', h3: '8a', heatmap: true }
);
});

test('readHash: empty hash -> defaults', () => {
assert.deepEqual(
readHash(''),
{ v: 0, lat: null, lng: null, alt: null, heading: 0, pitch: -90, mode: null, pid: null, h3: null, heatmap: false }
);
});

test('readHash: clamps lat/lng/alt and treats heatmap!=1 as false', () => {
const h = readHash('#lat=999&lng=-999&alt=50&heatmap=0');
assert.equal(h.lat, 90); // clamp to +90
assert.equal(h.lng, -180); // clamp to -180
assert.equal(h.alt, 100); // clamp to min 100
assert.equal(h.heading, 0); // default
assert.equal(h.heatmap, false);
});
Loading
Loading