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
66 changes: 66 additions & 0 deletions packages/core/src/components/scatter-plot/context-menu.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// packages/core/src/components/scatter-plot/context-menu.styles.ts
import { css } from 'lit';

export const contextMenuStyles = css`
:host {
position: absolute;
z-index: var(--z-dropdown, 100);
pointer-events: auto;
}

.menu {
position: absolute;
background: var(--surface, #fff);
border: var(--border-width, 1px) solid var(--border, #e2e8f0);
border-radius: var(--radius, 6px);
padding: 4px 0;
min-width: 180px;
box-shadow: var(--shadow-lg, 0 8px 30px rgba(0, 0, 0, 0.12));
font-size: var(--text-base, 12px);
font-family: inherit;
}

.menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 14px;
color: var(--text-primary, #334155);
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
font: inherit;
}

.menu-item:hover {
background: var(--primary-light, #eef6fb);
color: var(--primary, #00a3e0);
}

.menu-item[aria-disabled='true'] {
color: var(--text-tertiary, #a0aec0);
cursor: default;
pointer-events: none;
}

.menu-item .icon {
width: 16px;
text-align: center;
font-size: 13px;
flex-shrink: 0;
}

.menu-item .shortcut {
margin-left: auto;
font-size: var(--text-xs, 10px);
color: var(--text-secondary, #5b6b7a);
}

.separator {
height: 1px;
background: var(--border, #e2e8f0);
margin: 4px 0;
}
`;
32 changes: 32 additions & 0 deletions packages/core/src/components/scatter-plot/context-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// packages/core/src/components/scatter-plot/context-menu.test.ts
import { describe, it, expect } from 'vitest';
import { resolveMenuItems } from './context-menu';

describe('context-menu', () => {
describe('resolveMenuItems', () => {
it('returns point actions when a point is hit', () => {
const items = resolveMenuItems({
proteinId: 'P0DM09',
hasAccession: true,
dataCoords: [10, 20],
});
const types = items.map((i) => i.action.type);
expect(types).toContain('copy-id');
expect(types).toContain('view-uniprot');
});

it('disables view-uniprot when no accession', () => {
const items = resolveMenuItems({
proteinId: 'custom_001',
hasAccession: false,
dataCoords: [10, 20],
});
const uniprotItem = items.find((i) => i.action.type === 'view-uniprot');
expect(uniprotItem?.disabled).toBe(true);
});

it('returns no items when no point is hit', () => {
expect(resolveMenuItems(null)).toEqual([]);
});
});
});
124 changes: 124 additions & 0 deletions packages/core/src/components/scatter-plot/context-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// packages/core/src/components/scatter-plot/context-menu.ts
import { LitElement, html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { contextMenuStyles } from './context-menu.styles';

export interface ContextMenuAction {
type: 'copy-id' | 'view-uniprot';
proteinId: string;
}

export interface MenuItem {
label: string;
icon: string;
action: ContextMenuAction;
shortcut?: string;
disabled?: boolean;
separator?: boolean;
}

interface PointHit {
proteinId: string;
hasAccession: boolean;
dataCoords: [number, number];
}

export function resolveMenuItems(hit: PointHit | null): MenuItem[] {
if (!hit) return [];
return [
{
label: 'Copy ID',
icon: '📋',
action: { type: 'copy-id', proteinId: hit.proteinId },
},
{
label: 'View in UniProt',
icon: '🔗',
action: { type: 'view-uniprot', proteinId: hit.proteinId },
disabled: !hit.hasAccession,
},
];
}

@customElement('protspace-context-menu')
class ProtspaceContextMenu extends LitElement {
static styles = contextMenuStyles;

@property({ type: Boolean }) open = false;
@property({ type: Array }) items: MenuItem[] = [];

// Use composedPath() to correctly detect clicks inside nested shadow DOMs.
// With nested shadow DOM, e.target at the document level is retargeted to
// the outermost shadow host, so this.contains(e.target) always returns false.
private _onClickOutside = (e: MouseEvent) => {
if (!e.composedPath().includes(this)) {
this._close();
}
};

private _onEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
this._close();
}
};

connectedCallback() {
super.connectedCallback();
document.addEventListener('mousedown', this._onClickOutside);
document.addEventListener('keydown', this._onEscape);
}

disconnectedCallback() {
document.removeEventListener('mousedown', this._onClickOutside);
document.removeEventListener('keydown', this._onEscape);
super.disconnectedCallback();
}

private _close() {
this.open = false;
this.dispatchEvent(new CustomEvent('context-menu-close'));
}

private _handleItemClick(item: MenuItem) {
if (item.disabled) return;
this.dispatchEvent(
new CustomEvent<ContextMenuAction>('context-menu-action', {
detail: item.action,
bubbles: true,
composed: true,
}),
);
this._close();
}

render() {
if (!this.open) return nothing;

return html`
<div class="menu" role="menu">
${this.items.map((item) =>
item.separator
? html`<div class="separator"></div>`
: html`
<button
class="menu-item"
role="menuitem"
aria-disabled="${item.disabled ? 'true' : 'false'}"
@click="${() => this._handleItemClick(item)}"
>
<span class="icon">${item.icon}</span>
<span>${item.label}</span>
${item.shortcut ? html`<span class="shortcut">${item.shortcut}</span>` : nothing}
</button>
`,
)}
</div>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'protspace-context-menu': ProtspaceContextMenu;
}
}
70 changes: 69 additions & 1 deletion packages/core/src/components/scatter-plot/scatter-plot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { scatterplotStyles } from './scatter-plot.styles';
import './projection-metadata';
import './protspace-tips';
import './protein-tooltip';
import './context-menu';
import { resolveMenuItems, type MenuItem, type ContextMenuAction } from './context-menu';
import { DEFAULT_CONFIG } from './config';
import { createStyleGetters } from './style-getters';
import { MAX_POINTS_DIRECT_RENDER, WebGLRenderer } from './webgl';
Expand Down Expand Up @@ -76,6 +78,10 @@ export class ProtspaceScatterplot extends LitElement {
@property({ type: Boolean, attribute: 'show-tour-button' }) showTourButton = false;

// State
@state() private _contextMenuOpen = false;
@state() private _contextMenuX = 0;
@state() private _contextMenuY = 0;
@state() private _contextMenuItems: MenuItem[] = [];
@state() private _plotData: PlotDataPoint[] = [];
@state() private _tooltipData: {
x: number;
Expand Down Expand Up @@ -2037,12 +2043,66 @@ export class ProtspaceScatterplot extends LitElement {
return `left: ${left}px; top: ${top}px;${transform ? ` transform: ${transform};` : ''}`;
}

private _hitTestAtClient(
clientX: number,
clientY: number,
): { proteinId: string; dataCoords: [number, number] } | null {
if (!this._svg || !this._scales) return null;
const rect = this._svg.getBoundingClientRect();
const localX = clientX - rect.left;
const localY = clientY - rect.top;
const dataX = (localX - this._transform.x) / this._transform.k;
const dataY = (localY - this._transform.y) / this._transform.k;
const searchRadius = 15 / this._transform.k;
const nearest = this._quadtreeIndex.findNearest(dataX, dataY, searchRadius);
if (!nearest) return null;
if (this._getOpacity(nearest) === 0) return null;
if (this._webglRenderer && !this._webglRenderer.isPointRendered(nearest.id)) return null;
return { proteinId: nearest.id, dataCoords: [nearest.x, nearest.y] };
}

private _handleContextMenu(e: MouseEvent) {
const hit = this._hitTestAtClient(e.clientX, e.clientY);
if (!hit) {
this._contextMenuOpen = false;
return;
}
e.preventDefault();
const hasAccession = /^[A-Z][0-9][A-Z0-9]{3}[0-9]|^[A-Z]{2}_\d+/.test(hit.proteinId);
this._contextMenuItems = resolveMenuItems({ ...hit, hasAccession });
const rect = this.getBoundingClientRect();
this._contextMenuX = e.clientX - rect.left;
this._contextMenuY = e.clientY - rect.top;
this._contextMenuOpen = true;
}

private _handleContextMenuAction(e: CustomEvent<ContextMenuAction>) {
e.stopPropagation();
const { type, proteinId } = e.detail;
if (type === 'copy-id') {
void navigator.clipboard?.writeText(proteinId);
} else if (type === 'view-uniprot') {
window.open(`https://www.uniprot.org/uniprotkb/${proteinId}/entry`, '_blank', 'noopener');
}
this.dispatchEvent(
new CustomEvent('context-menu-action', {
detail: e.detail,
bubbles: true,
composed: true,
}),
);
}

private _handleContextMenuClose() {
this._contextMenuOpen = false;
}

render() {
const config = this._mergedConfig;
const useAltCanvas = this._canvasKey % 2 === 1;

return html`
<div class="container">
<div class="container" @contextmenu="${this._handleContextMenu}">
<!-- Canvas for high-performance rendering (always visible for better performance) -->
${useAltCanvas
? html`<canvas
Expand Down Expand Up @@ -2117,6 +2177,14 @@ export class ProtspaceScatterplot extends LitElement {
</div>
`
: ''}
<protspace-context-menu
style="position: absolute; left:${this._contextMenuX}px; top:${this
._contextMenuY}px; z-index: 20;"
.open="${this._contextMenuOpen}"
.items="${this._contextMenuItems}"
@context-menu-action="${this._handleContextMenuAction}"
@context-menu-close="${this._handleContextMenuClose}"
></protspace-context-menu>
</div>
`;
}
Expand Down
Loading