From 8f87f05749192dd48a11089f9a45e43a755cd348 Mon Sep 17 00:00:00 2001 From: Justin Teo <116620038+justintyf01@users.noreply.github.com> Date: Tue, 9 Jul 2024 16:24:41 +0800 Subject: [PATCH 1/2] Implemented nightingale-tooltip component --- .../src/nightingale-manager.ts | 321 ++++++++++-------- .../src/utils/bindEvents.ts | 73 ++-- packages/nightingale-tooltip/package.json | 38 +++ packages/nightingale-tooltip/src/index.ts | 2 + .../src/nightingale-tooltip.ts | 107 ++++++ packages/nightingale-tooltip/tsconfig.json | 7 + .../src/nightingale-track.ts | 3 +- 7 files changed, 371 insertions(+), 180 deletions(-) create mode 100644 packages/nightingale-tooltip/package.json create mode 100644 packages/nightingale-tooltip/src/index.ts create mode 100644 packages/nightingale-tooltip/src/nightingale-tooltip.ts create mode 100644 packages/nightingale-tooltip/tsconfig.json diff --git a/packages/nightingale-manager/src/nightingale-manager.ts b/packages/nightingale-manager/src/nightingale-manager.ts index 82804170f..769e3e5bf 100644 --- a/packages/nightingale-manager/src/nightingale-manager.ts +++ b/packages/nightingale-manager/src/nightingale-manager.ts @@ -3,155 +3,196 @@ import NightingaleElement from "@nightingale-elements/nightingale-new-core"; @customElement("nightingale-manager") class NightingaleManager extends NightingaleElement { - @property({ - converter: { - fromAttribute: (value): Map | null => { - if (!value) { - return null; - } - const attributes = value.split(","); - if (attributes.indexOf("type") !== -1) - throw new Error("'type' can't be used as a protvista attribute"); - if (attributes.indexOf("value") !== -1) - throw new Error("'value' can't be used as a protvista attribute"); - const mapToReturn = new Map( - attributes - .filter( - (attr: string) => - !NightingaleManager.observedAttributes.includes(attr), - ) - .map((attr: string) => [attr, null]), - ); - return mapToReturn; - }, - toAttribute: (value: []) => { - return value.join(","); - }, - }, - }) - "reflected-attributes"?: Map = new Map(); - - @property({ type: Number }) - length?: number; - - @property({ type: Number }) - "display-start"?: number; - - @property({ type: Number }) - "display-end"?: number; - - @property({ type: String }) - "highlight"?: string; - - @state() - protvistaElements = new Set(); - - @state() - propertyValues = new Map(); - - connectedCallback() { - super.connectedCallback(); - this.addEventListener("change", this.changeListener as EventListener); - this.style.display = "unset"; - } - - override attributeChangedCallback( - attr: string, - previousValue: string | null, - newValue: string | null, - ) { - super.attributeChangedCallback(attr, previousValue, newValue); - this.applyAttributes(); - } - - applyAttributes() { - this.protvistaElements.forEach((element: HTMLElement) => { - this["reflected-attributes"]?.forEach((value, type) => { - if (value === false || value === null || value === undefined) { - element.removeAttribute(type); + @property({ + converter: { + fromAttribute: (value): Map | null => { + if (!value) { + return null; + } + const attributes = value.split(","); + if (attributes.indexOf("type") !== -1) throw new Error("'type' can't be used as a protvista attribute"); + if (attributes.indexOf("value") !== -1) throw new Error("'value' can't be used as a protvista attribute"); + const mapToReturn = new Map(attributes.filter((attr: string) => !NightingaleManager.observedAttributes.includes(attr)).map((attr: string) => [attr, null])); + return mapToReturn; + }, + toAttribute: (value: []) => { + return value.join(","); + }, + }, + }) + "reflected-attributes"?: Map = new Map(); + + @property({ type: Number }) + length?: number; + + @property({ type: Number }) + "display-start"?: number; + + @property({ type: Number }) + "display-end"?: number; + + @property({ type: String }) + "highlight"?: string; + + @state() + protvistaElements = new Set(); + + @state() + propertyValues = new Map(); + + connectedCallback() { + super.connectedCallback(); + this.addEventListener("change", this.changeListener as EventListener); + document.addEventListener("click", this.handleDocumentClick.bind(this)); + this.style.display = "unset"; + } + + override attributeChangedCallback(attr: string, previousValue: string | null, newValue: string | null) { + super.attributeChangedCallback(attr, previousValue, newValue); + this.applyAttributes(); + } + + applyAttributes() { + this.protvistaElements.forEach((element: HTMLElement) => { + this["reflected-attributes"]?.forEach((value, type) => { + if (value === false || value === null || value === undefined) { + element.removeAttribute(type); + } else { + element.setAttribute(type, typeof value === "boolean" ? "" : value); + } + }); + // Default properties + if (this.length) { + element.setAttribute("length", `${this.length}`); + } + if (this["display-end"]) { + element.setAttribute("display-end", `${this["display-end"]}`); + } + if (this["display-start"]) { + element.setAttribute("display-start", `${this["display-start"]}`); + } + if (this.highlight) { + element.setAttribute("highlight", this.highlight); + } + }); + } + + register(element: NightingaleElement) { + this.protvistaElements.add(element); + this.applyAttributes(); + } + + unregister(element: NightingaleElement) { + this.protvistaElements.delete(element); + } + + applyProperties(forElementId: string) { + if (forElementId) { + const element = this.querySelector(`#${forElementId}`) as HTMLElement; + if (!element) { + return; + } + this.propertyValues.forEach((value, type) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (element as any)[type] = value; + }); } else { - element.setAttribute(type, typeof value === "boolean" ? "" : value); + this.protvistaElements.forEach((element: HTMLElement) => { + if (!element) { + return; + } + this.propertyValues.forEach((value, type) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (element as any)[type] = value; + }); + }); } - }); - // Default properties - if (this.length) { - element.setAttribute("length", `${this.length}`); - } - if (this["display-end"]) { - element.setAttribute("display-end", `${this["display-end"]}`); - } - if (this["display-start"]) { - element.setAttribute("display-start", `${this["display-start"]}`); - } - if (this.highlight) { - element.setAttribute("highlight", this.highlight); - } - }); - } - - register(element: NightingaleElement) { - this.protvistaElements.add(element); - this.applyAttributes(); - } - - unregister(element: NightingaleElement) { - this.protvistaElements.delete(element); - } - - applyProperties(forElementId: string) { - if (forElementId) { - const element = this.querySelector(`#${forElementId}`) as HTMLElement; - if (!element) { - return; - } - this.propertyValues.forEach((value, type) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (element as any)[type] = value; - }); - } else { - this.protvistaElements.forEach((element: HTMLElement) => { - if (!element) { - return; + } + + isRegisteredAttribute(attributeName: string) { + if (!this["reflected-attributes"]) { + return false; } - this.propertyValues.forEach((value, type) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (element as any)[type] = value; - }); - }); + return [...this["reflected-attributes"].keys()].includes(attributeName) || NightingaleManager.observedAttributes.includes(attributeName); } - } - isRegisteredAttribute(attributeName: string) { - if (!this["reflected-attributes"]) { - return false; + // This function is the click listener for the entire document - responsible for closing tooltips and de-highlighting + handleDocumentClick(event: MouseEvent) { + const isClickInsideFeatureOrTooltip = event.composedPath().some((element) => { + const el = element as HTMLElement; + return el.classList?.contains("outer-rectangle") || (el.nodeType === Node.ELEMENT_NODE && (el as HTMLElement).closest("nightingale-tooltip")); + }); + + // If click occurs outside of tooltip or feature track, remove and de-highlight + if (!isClickInsideFeatureOrTooltip) { + const tooltip = this.querySelector("nightingale-tooltip"); + if (tooltip) { + const tooltipElement = tooltip.shadowRoot?.querySelector(".tooltip"); + if (tooltipElement && !tooltipElement.contains(event.target as Node)) { + (tooltip as any).hideTooltip(); + this.highlight = "null"; + this.applyAttributes(); + } + } + } } - return ( - [...this["reflected-attributes"].keys()].includes(attributeName) || - NightingaleManager.observedAttributes.includes(attributeName) - ); - } - - changeListener(e: CustomEvent) { - if (!e.detail) { - return; + + handleTrackClick(event: CustomEvent) { + if (event.detail?.eventType === "click") { + event.stopPropagation(); // Prevent event from bubbling up to document + const tooltip = this.querySelector("nightingale-tooltip"); + + if (!tooltip) { + console.error("Tooltip element not found in the DOM."); + return; + } + + const [x, y] = event.detail.coords; + + // Ensure `showTooltip` exists on the element + if (typeof (tooltip as any).showTooltip === "function") { + let feature = event.detail.feature; + if (feature) { + (tooltip as any).showTooltip( + x, + y, + { + description: feature.description || "Random description", + evidence: feature.evidence || "Random evidence", + }, + feature.accession + ); + + this.highlight = event.detail.highlight; + } + } else { + console.error("Tooltip element does not have a showTooltip method."); + } + } } - switch (e.detail.handler) { - case "property": - this.propertyValues.set(e.detail.type, e.detail.value); - this.applyProperties(e.detail.for); - break; - default: - if (this.isRegisteredAttribute(e.detail.type)) { - this["reflected-attributes"]?.set(e.detail.type, e.detail.value); + + changeListener(e: CustomEvent) { + if (!e.detail) { + return; + } + switch (e.detail.handler) { + case "property": + this.propertyValues.set(e.detail.type, e.detail.value); + this.applyProperties(e.detail.for); + break; + default: + if (this.isRegisteredAttribute(e.detail.type)) { + this["reflected-attributes"]?.set(e.detail.type, e.detail.value); + } + Object.keys(e.detail).forEach((key) => { + if (this.isRegisteredAttribute(key)) { + this["reflected-attributes"]?.set(key, e.detail[key]); + } + }); + this.handleTrackClick(e); + this.applyAttributes(); } - Object.keys(e.detail).forEach((key) => { - if (this.isRegisteredAttribute(key)) { - this["reflected-attributes"]?.set(key, e.detail[key]); - } - }); - this.applyAttributes(); } - } } export default NightingaleManager; diff --git a/packages/nightingale-new-core/src/utils/bindEvents.ts b/packages/nightingale-new-core/src/utils/bindEvents.ts index 063cd6f59..ee5d84ad7 100644 --- a/packages/nightingale-new-core/src/utils/bindEvents.ts +++ b/packages/nightingale-new-core/src/utils/bindEvents.ts @@ -23,7 +23,7 @@ type SequenceBaseData = { type detailInterface = { eventType: EventType; - // coords: null | [number, number]; + coords: null | [number, number]; feature?: FeatureData | SequenceBaseData | null; target?: HTMLElement; highlight?: string; @@ -51,6 +51,7 @@ export function createEvent( eventType: type, // TODO: add coordinates // coords: WithNightingaleEvents._getClickCoords(), + coords: event ? [(event as MouseEvent).clientX, (event as MouseEvent).clientY]: null, feature, target, parentEvent: event, @@ -83,42 +84,36 @@ export default function bindEvents( element: NightingaleBaseElement, ) { feature - .on("mouseover", function (event: Event, datum: unknown) { - element.dispatchEvent( - createEvent( - "mouseover", - datum as FeatureData | SequenceBaseData, - element.getAttribute(HIGHLIGHT_EVENT) === "onmouseover", - false, - (datum as FeatureData).start ?? (datum as SequenceBaseData).position, - (datum as FeatureData).end ?? (datum as SequenceBaseData).position, - this as unknown as HTMLElement, - event, - ), - ); - }) - .on("mouseout", () => { - element.dispatchEvent( - createEvent( - "mouseout", - null, - element.getAttribute(HIGHLIGHT_EVENT) === "onmouseover", - ), - ); - }) - .on("click", function (event: Event, datum: unknown) { - element.dispatchEvent( - createEvent( - "click", - datum as FeatureData | SequenceBaseData, - element.getAttribute(HIGHLIGHT_EVENT) === "onclick", - true, - (datum as FeatureData).start ?? (datum as SequenceBaseData).position, - (datum as FeatureData).end ?? (datum as SequenceBaseData).position, - this as unknown as HTMLElement, - event, - element as NightingaleBaseElement & WithHighlightInterface, - ), - ); - }); + .on("mouseover", function (event: MouseEvent, datum: unknown) { + element.dispatchEvent( + createEvent( + "mouseover", + datum as FeatureData | SequenceBaseData, + element.getAttribute(HIGHLIGHT_EVENT) === "onmouseover", + false, + (datum as FeatureData).start ?? (datum as SequenceBaseData).position, + (datum as FeatureData).end ?? (datum as SequenceBaseData).position, + this as unknown as HTMLElement, + event + ) + ); + }) + .on("mouseout", () => { + element.dispatchEvent(createEvent("mouseout", null, element.getAttribute(HIGHLIGHT_EVENT) === "onmouseover")); + }) + .on("click", function (event: MouseEvent, datum: unknown) { + element.dispatchEvent( + createEvent( + "click", + datum as FeatureData | SequenceBaseData, + element.getAttribute(HIGHLIGHT_EVENT) === "onclick", + true, + (datum as FeatureData).start ?? (datum as SequenceBaseData).position, + (datum as FeatureData).end ?? (datum as SequenceBaseData).position, + this as unknown as HTMLElement, + event, + element as NightingaleBaseElement & WithHighlightInterface + ) + ); + }); } diff --git a/packages/nightingale-tooltip/package.json b/packages/nightingale-tooltip/package.json new file mode 100644 index 000000000..e16c62835 --- /dev/null +++ b/packages/nightingale-tooltip/package.json @@ -0,0 +1,38 @@ +{ + "name": "@nightingale-elements/nightingale-tooltip", + "version": "5.0.0", + "description": "Tooltip component for the Nightingale tool", + "files": [ + "dist", + "src" + ], + "main": "dist/index.js", + "module": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "scripts": { + "build": "rollup --config ../../rollup.config.mjs", + "test": "../../node_modules/.bin/jest --config ../../jest.config.js ./tests/*" + }, + "license": "ISC", + "keywords": [ + "nightingale", + "webcomponents", + "customelements" + ], + "repository": { + "type": "git", + "url": "https://github.com/ebi-webcomponents/nightingale.git" + }, + "bugs": { + "url": "https://github.com/ebi-webcomponents/nightingale/issues" + }, + "homepage": "https://ebi-webcomponents.github.io/nightingale/", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@nightingale-elements/nightingale-new-core": "^5.0.0", + "d3": "7.9.0" + } +} diff --git a/packages/nightingale-tooltip/src/index.ts b/packages/nightingale-tooltip/src/index.ts new file mode 100644 index 000000000..cc0f826ce --- /dev/null +++ b/packages/nightingale-tooltip/src/index.ts @@ -0,0 +1,2 @@ +import NightingaleTooltip from "./nightingale-tooltip"; +export default NightingaleTooltip; \ No newline at end of file diff --git a/packages/nightingale-tooltip/src/nightingale-tooltip.ts b/packages/nightingale-tooltip/src/nightingale-tooltip.ts new file mode 100644 index 000000000..572356f66 --- /dev/null +++ b/packages/nightingale-tooltip/src/nightingale-tooltip.ts @@ -0,0 +1,107 @@ +import { html, css, LitElement } from "lit"; +import { property, customElement } from "lit/decorators.js"; + +@customElement("nightingale-tooltip") +class NightingaleTooltip extends LitElement { + @property({ type: String, reflect: true }) + title: string = ""; + + @property({ type: Boolean, reflect: true }) + visible: boolean = false; + + @property({ type: Object }) + tooltipContent: { description: string; evidence: string } = { description: "", evidence: "" }; + + @property({ type: String, reflect: true }) + container: string = "html"; + + static styles = css` + :host { + --z-index: 50000; + --title-color: black; + --text-color: white; + --body-color: #616161; + --triangle-width: 16px; + --triangle-height: 10px; + --triangle-margin: 10px; + --vertical-distance: 5px; + } + + .tooltip { + font-family: Roboto, Arial, sans-serif; + font-size: 0.9rem; + display: none; /* Hide initially */ + position: absolute; + min-width: 220px; + max-width: 50vw; + z-index: var(--z-index); + color: var(--text-color); + background: var(--body-color); + padding: 10px; + border-radius: 5px; + pointer-events: none; + transition: opacity 0.3s; + white-space: normal; /* Ensure text wraps */ + word-wrap: break-word; /* Ensure long words break */ + line-height: 1.5; /* Add line height for better readability */ + } + + .tooltip.visible { + display: block; /* Show when visible */ + opacity: 0.9; + pointer-events: auto; + } + + h1 { + margin: 0; + background-color: var(--title-color); + line-height: 2em; + padding: 0 1ch; + } + + .tooltip-body { + padding: 1em; + background: var(--body-color); + font-weight: normal; + overflow-y: auto; + max-height: 40vh; + } + + p { + margin: 0; /* Remove default margin to avoid extra space */ + padding: 0; /* Remove default padding to avoid extra space */ + } + `; + + render() { + return html` +
+

${this.title}

+
+

Description: ${this.tooltipContent.description}

+

Evidence: ${this.tooltipContent.evidence}

+
+
+ `; + } + + showTooltip(x: number, y: number, content: { description: string; evidence: string }, accession: string) { + // Hide tooltip before displaying new one + this.hideTooltip(); + + const tooltip = this.shadowRoot?.querySelector(".tooltip") as HTMLElement; + if (tooltip) { + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + this.tooltipContent = content; + this.title = accession; + this.visible = true; + } + } + + hideTooltip() { + this.visible = false; + } +} + +export default NightingaleTooltip; diff --git a/packages/nightingale-tooltip/tsconfig.json b/packages/nightingale-tooltip/tsconfig.json new file mode 100644 index 000000000..6a62dbc48 --- /dev/null +++ b/packages/nightingale-tooltip/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/nightingale-track/src/nightingale-track.ts b/packages/nightingale-track/src/nightingale-track.ts index ceb72fddd..1f3cb851d 100644 --- a/packages/nightingale-track/src/nightingale-track.ts +++ b/packages/nightingale-track/src/nightingale-track.ts @@ -35,7 +35,8 @@ export type Feature = { color?: string; fill?: string; shape?: Shapes; - tooltipContent?: string; + description?: string; + evidence?: string; type?: string; locations?: Array; feature?: Feature; From dbb93d3b3b8d13558bcd5a9ef8c85cfcef646a82 Mon Sep 17 00:00:00 2001 From: Justin Teo <116620038+justintyf01@users.noreply.github.com> Date: Tue, 9 Jul 2024 16:25:12 +0800 Subject: [PATCH 2/2] Added nightingale-tooltip README --- packages/nightingale-tooltip/README.md | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 packages/nightingale-tooltip/README.md diff --git a/packages/nightingale-tooltip/README.md b/packages/nightingale-tooltip/README.md new file mode 100644 index 000000000..4744aa585 --- /dev/null +++ b/packages/nightingale-tooltip/README.md @@ -0,0 +1,39 @@ +# nightingale-tooltip + +!Not published + +Tooltip component that is triggered when user selects a track. Tooltip shows feature accession as `title` and contents include `description` and `evidence`. + +Does not affect sequence highlighting by `nightingale-track` + +## Usage + +```html + + + + +``` + +## API Reference + +### Properties + +#### `showTooltip(x: number, y: number, content: { description: string; evidence: string }, accession: string)`: + +x and y: coordinates of MouseEvent (click) +Hides any current open tooltip, and opens a new one with the given parameters. Sets visible flag on `nightingale-tooltip` component to true. + +#### `unregister(element: NightingaleElement)`: + +Sets visible flag on `nightingale-tooltip` component to false. + +### Property + +#### `title: string | ""` + +#### `visible: boolean | false` + +#### `tooltipContent: { description: string, evidence: string } | { description: "", evidence: "" }` + +#### `container: string | "html"`