Skip to content
2 changes: 1 addition & 1 deletion dist/advanced.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<body>
<header>
<nav class="center">
<h1><a href="index.html">SynoSpecies</a></h1>
<h1><a href="/">SynoSpecies</a></h1>
<div class="pages">
<a class="inactive" href="index.html">Search</a>
<a class="active" href="advanced.html">SPARQL</a>
Expand Down
12 changes: 12 additions & 0 deletions dist/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ header {
margin: 0.8rem 0 0;
}

/* the custom URL and its "Use" button */
.custom-endpoint {
align-items: end;
display: flex;
gap: 0.4rem;

input {
flex: 1;
min-width: 0;
}
}

.error {
color: light-dark(#c62828, #ff8a80);
}
Expand Down
2 changes: 1 addition & 1 deletion dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
</script>
<header>
<nav class="center">
<h1><a href="index.html">SynoSpecies</a></h1>
<h1><a href="/">SynoSpecies</a></h1>
Comment thread
retog marked this conversation as resolved.
<div class="pages">
<a class="active" href="index.html">Search</a>
<a class="inactive" href="advanced.html">SPARQL</a>
Expand Down
2 changes: 1 addition & 1 deletion dist/info.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<body>
<header>
<nav class="center">
<h1><a href="index.html">SynoSpecies</a></h1>
<h1><a href="/">SynoSpecies</a></h1>
<div class="pages">
<a class="inactive" href="index.html">Search</a>
<a class="inactive" href="advanced.html">SPARQL</a>
Expand Down
2 changes: 1 addition & 1 deletion dist/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<body>
<header>
<nav class="center">
<h1><a href="index.html">SynoSpecies</a></h1>
<h1><a href="/">SynoSpecies</a></h1>
<div class="pages">
<a class="inactive" href="index.html">Search</a>
<a class="inactive" href="advanced.html">SPARQL</a>
Expand Down
16 changes: 8 additions & 8 deletions src/advanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ const queryPrefixes = {
dwc: "http://rs.tdwg.org/dwc/terms/",
dc: "http://purl.org/dc/elements/1.1/",
trt: "http://plazi.org/vocab/treatment#",
treatment: "http://treatment.plazi.org/id/",
// taxonConcept: "http://taxon-concept.plazi.org/id/",
// taxonName: "http://taxon-name.plazi.org/id/",
publication: "http://publication.plazi.org/id/",
treatment: "https://treatment.plazi.org/id/",
// taxonConcept: "https://taxon-concept.plazi.org/id/",
// taxonName: "https://taxon-name.plazi.org/id/",
publication: "https://publication.plazi.org/id/",
};

const resultsPrefixes = {
Expand Down Expand Up @@ -226,20 +226,20 @@ const exampleQueries: exampleQuery[] = [
A good way to get get a first impression as to how the data is structured is using <code class="uri">DESCRIBE</code>-queries.
Try also to describe the objects that this first query returns.
`,
query: `DESCRIBE <http://treatment.plazi.org/id/962587B2FFF8FF9EFF2A38EEF59DB87E>`,
query: `DESCRIBE <https://treatment.plazi.org/id/962587B2FFF8FF9EFF2A38EEF59DB87E>`,
},
{
title: "All synonyms",
description: `
This query returns all synonyms of
<code class="uri taxon">http://taxon-concept.plazi.org/id/Animalia/Tyrannosaurus_rex_Osborn_1905</code>.
<code class="uri taxon">https://taxon-concept.plazi.org/id/Animalia/Tyrannosaurus_rex_Osborn_1905</code>.
It uses a transitive property path to get the taxa (Taxon-Concepts)
augmented or defined by a treatmented that deprecates this taxon or that are
deprecated by a treatement that defines or deprecates this taxon.
`,
query: `SELECT DISTINCT *
WHERE {
<http://taxon-concept.plazi.org/id/Animalia/Tyrannosaurus_rex_Osborn_1905> ((^trt:deprecates/(trt:augmentsTaxonConcept|trt:definesTaxonConcept))|((^trt:augmentsTaxonConcept|^trt:definesTaxonConcept)/trt:deprecates))* ?tc .
<https://taxon-concept.plazi.org/id/Animalia/Tyrannosaurus_rex_Osborn_1905> ((^trt:deprecates/(trt:augmentsTaxonConcept|trt:definesTaxonConcept))|((^trt:augmentsTaxonConcept|^trt:definesTaxonConcept)/trt:deprecates))* ?tc .
}`,
},
{
Expand Down Expand Up @@ -348,7 +348,7 @@ WHERE {
`This query returns all material citations for all synonyms of Tyrannosaurus rex that are in the New Mexico Museum of Natural History & Science (NMMNH).`,
query: `SELECT DISTINCT ?syn ?catalogNumber # ?collectionCode
WHERE {
<http://taxon-concept.plazi.org/id/Animalia/Tyrannosaurus_rex_Osborn_1905> ((^trt:deprecates/(trt:augmentsTaxonConcept|trt:definesTaxonConcept))|((^trt:augmentsTaxonConcept|^trt:definesTaxonConcept)/trt:deprecates))* ?syn .
<https://taxon-concept.plazi.org/id/Animalia/Tyrannosaurus_rex_Osborn_1905> ((^trt:deprecates/(trt:augmentsTaxonConcept|trt:definesTaxonConcept))|((^trt:augmentsTaxonConcept|^trt:definesTaxonConcept)/trt:deprecates))* ?syn .
?treat (trt:definesTaxonConcept|trt:augmentsTaxonConcept|trt:deprecates) ?syn ;
dwc:basisOfRecord ?mc .
# This might miss some specimens where the ‘dwc:collectionCode’ was entered in a non-standard way.
Expand Down
135 changes: 101 additions & 34 deletions src/components/SettingsMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ const LINK = "link";

/**
* The settings menu in the page header: a gear button opening a popover in
* which the SPARQL endpoint can be chosen. A choice is stored right away and
* announced with an `endpoint-change` event (bubbling and composed, with the
* URL as `detail`), so that the page can switch to the new endpoint.
* which the SPARQL endpoint can be chosen. A known endpoint is stored right
* away and announced with an `endpoint-change` event (bubbling and composed,
* with the URL as `detail`), so that the page can switch to it. A custom URL
* is tested first and only stored and announced once the test has passed, or
* when the user decides to use it anyway.
*
* Renders into the light DOM, so the page's stylesheet applies.
*/
Expand All @@ -43,6 +45,10 @@ export class SettingsMenu extends LitElement {
@state()
accessor custom: string = "";

/** A custom URL being tested, or one that failed; not stored yet. */
@state()
accessor pending: string | null = null;

@state()
accessor test: TestResult | null = null;

Expand All @@ -57,34 +63,66 @@ export class SettingsMenu extends LitElement {

private abortTest?: AbortController;

/** The animation frame in which the popover is placed again, if any. */
private layoutFrame = 0;

protected override createRenderRoot() {
return this;
}

override connectedCallback() {
super.connectedCallback();
this.reflectStored();
addEventListener("resize", this.close);
addEventListener("scroll", this.close, { passive: true });
addEventListener("resize", this.onLayoutChange);
addEventListener("scroll", this.onLayoutChange, { passive: true });
}

override disconnectedCallback() {
removeEventListener("resize", this.close);
removeEventListener("scroll", this.close);
removeEventListener("resize", this.onLayoutChange);
removeEventListener("scroll", this.onLayoutChange);
cancelAnimationFrame(this.layoutFrame);
this.layoutFrame = 0;
super.disconnectedCallback();
}

/** The popover is placed next to the gear, so it closes when that moves. */
private readonly close = () => this.menu?.hidePopover();
/**
* The popover is placed next to the gear: it follows the gear when the page
* is resized or scrolled (as on phones when the on-screen keyboard opens or
* closes), once per frame, and closes when the gear leaves the view.
*/
private readonly onLayoutChange = () => {
Comment thread
retog marked this conversation as resolved.
if (this.layoutFrame) return;
this.layoutFrame = requestAnimationFrame(() => {
this.layoutFrame = 0;
if (!this.menu?.matches(":popover-open")) return;
const { top, bottom } = this.button.getBoundingClientRect();
if (bottom > 0 && top < innerHeight) this.position();
else this.menu.hidePopover();
});
};

/** Whether the page uses a server named in its link instead of the setting. */
private get fromLink() {
return getEndpoint() !== this.endpoint;
}

private reflectStored() {
const stored = getStoredEndpoint();
// a test result belongs to the endpoint it was run for
if (stored !== this.endpoint) this.clearTest();
// a test result and a draft belong to the setting they were made for
if (stored !== this.endpoint) {
this.clearTest();
this.pending = null;
}
this.endpoint = stored;
// a custom URL not used yet stays in the menu with its test result
if (this.pending !== null) {
this.choice = CUSTOM;
this.custom = this.pending;
return;
}
// while the page uses a server named in its link, nothing is selected, so
// that choosing any endpoint (also the stored one) counts as a change
this.choice = getEndpoint() !== this.endpoint
this.choice = this.fromLink
? LINK
: isKnownEndpoint(this.endpoint)
? this.endpoint
Expand Down Expand Up @@ -118,16 +156,35 @@ export class SettingsMenu extends LitElement {
return;
}
this.clearTest();
this.pending = null;
// a stored custom endpoint is offered for editing; it is used again with
// the field's "Use" button, e.g. to leave a server named in the link
this.custom = isKnownEndpoint(this.endpoint) ? "" : this.endpoint;
this.updateComplete.then(() => this.customInput?.focus());
}

/** Uses the URL in the field, also when it was not edited. */
private onCustomSubmit(e: SubmitEvent) {
e.preventDefault();
const input = this.customInput;
if (!input) return;
// Enter in the field fires `change` first, which already started a test
if (
this.pending === input.value.trim() && this.test?.state === "running"
) return;
this.useCustom(input);
}

private onCustomChange(e: Event) {
const input = e.target as HTMLInputElement;
this.useCustom(e.target as HTMLInputElement);
}

private useCustom(input: HTMLInputElement) {
const url = input.value.trim();
this.custom = url;
// the result shown belongs to the URL before the edit
// the draft and its result belong to the URL before the edit
this.clearTest();
this.pending = null;
const valid = isValidEndpoint(url);
input.setCustomValidity(
valid
Expand All @@ -142,17 +199,23 @@ export class SettingsMenu extends LitElement {

private save(url: string) {
this.clearTest();
this.endpoint = url;
setStoredEndpoint(url);
// a custom endpoint is checked first: the page switches once the test has
// passed (or on request), so that a failure is seen before e.g. the
// search reloads with it
if (isKnownEndpoint(url)) this.announce(url);
else this.runTest(url);
if (isKnownEndpoint(url)) {
this.apply(url);
return;
}
// a custom endpoint is checked first and applied once the test has passed
// (or on request), so that a failure is seen before e.g. the search
// reloads with it, and a failing URL is neither stored nor used
this.pending = url;
Comment thread
retog marked this conversation as resolved.
this.runTest(url);
Comment thread
retog marked this conversation as resolved.
}

/** Tells the page to use the endpoint. */
private announce(url: string) {
/** Stores the endpoint and tells the page to use it. */
private apply(url: string) {
this.pending = null;
setStoredEndpoint(url);
// read back, as storing fails silently when site data is blocked
this.endpoint = getStoredEndpoint();
this.dispatchEvent(
new CustomEvent("endpoint-change", {
detail: url,
Expand All @@ -176,7 +239,7 @@ export class SettingsMenu extends LitElement {
const treatments = await testEndpoint(url, abort.signal);
if (abort.signal.aborted) return;
this.test = { state: "ok", treatments };
if (treatments) this.announce(url);
if (treatments) this.apply(url);
} catch (error) {
if (abort.signal.aborted) return;
this.test = {
Expand All @@ -187,15 +250,15 @@ export class SettingsMenu extends LitElement {
}

override render() {
const fromLink = this.choice === LINK;
const pending = this.pending;
return html`
<button class="icon-button" type="button"
aria-label="Settings" title="Settings"
popovertarget="settings-menu"><s-icon icon="settings"></s-icon></button>
<div id="settings-menu" popover @beforetoggle=${this.onBeforeToggle}>
<label>SPARQL endpoint
<select @change=${this.onSelect}>
${fromLink
${this.choice === LINK
? html`
<option value=${LINK} disabled
.selected=${true}>Server from link: ${getEndpoint()
Expand All @@ -217,24 +280,28 @@ export class SettingsMenu extends LitElement {
</label>
${this.choice === CUSTOM
? html`
<input type="text" inputmode="url"
placeholder="https://example.org/sparql"
aria-label="Custom endpoint URL"
.value=${this.custom} @change=${this.onCustomChange}>
<form class="custom-endpoint" @submit=${this.onCustomSubmit}>
<input type="text" inputmode="url" enterkeyhint="go"
placeholder="https://example.org/sparql"
aria-label="Custom endpoint URL"
.value=${this.custom} @change=${this.onCustomChange}>
<button type="submit">Use</button>
</form>
`
: nothing}
${this.test
? html`<p>${renderTestResult(this.test)}${
this.test.state === "error" ||
(this.test.state === "ok" && !this.test.treatments)
pending !== null &&
(this.test.state === "error" ||
(this.test.state === "ok" && !this.test.treatments))
? html`
<button type="button" @click=${() =>
this.announce(this.endpoint)}>Use anyway</button>
this.apply(pending)}>Use anyway</button>
`
: nothing
}</p>`
: nothing}
${fromLink
${this.fromLink
? html`<p><small>This page uses the server named in its link.
Choose an endpoint to switch to it.</small></p>`
: nothing}
Expand Down
6 changes: 5 additions & 1 deletion src/components/SynoForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import Taxomplete from "taxomplete";

import { getEndpoint } from "../endpoints.ts";

/** Shown in the empty search field, and searched for when it stays empty. */
const EXAMPLE_NAME = "Sadayoshia acamar";

export class SynoForm extends HTMLElement {
connectedCallback() {
if (this.innerHTML) return;
Expand All @@ -17,6 +20,7 @@ export class SynoForm extends HTMLElement {

const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.placeholder = EXAMPLE_NAME;
if (NAME) nameInput.value = NAME;

const colCheckLabel = document.createElement("label");
Expand Down Expand Up @@ -71,7 +75,7 @@ export class SynoForm extends HTMLElement {

const go = () => {
const params = new URLSearchParams({
q: nameInput.value,
q: nameInput.value.trim() || EXAMPLE_NAME,
});
if (colCheck.checked) params.append("show_col", "");
if (subtaxaCheck.checked) params.append("subtaxa", "");
Expand Down
Loading
Loading