diff --git a/package.json b/package.json index df09ab8e7..affc28bda 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "visyn_core", "description": "Core repository for datavisyn applications.", - "version": "4.1.2-SNAPSHOT", + "version": "5.0.1-SNAPSHOT", "author": { "name": "datavisyn GmbH", "email": "contact@datavisyn.io", @@ -145,6 +145,7 @@ "@mantine/hooks": "~6.0.19", "@mantine/modals": "~6.0.19", "@mantine/notifications": "~6.0.19", + "@mantine/styles": "~6.0.19", "@types/d3-hexbin": "^0.2.3", "@types/d3v7": "npm:@types/d3@^7.4.0", "@types/plotly.js-dist-min": "^2.3.0", diff --git a/src/components/PermissionChooser.styles.ts b/src/components/PermissionChooser.styles.ts new file mode 100644 index 000000000..12f955453 --- /dev/null +++ b/src/components/PermissionChooser.styles.ts @@ -0,0 +1,26 @@ +import { createStyles } from '@mantine/core'; + +export default createStyles((theme) => ({ + grid: { + display: 'grid', + // minmax helps here to make it look good across resolutions ~450 - 1800 + gridTemplateColumns: 'minmax(5rem, 0.5fr) minmax(10rem, 2fr) minmax(20rem, 1fr)', + alignItems: 'center', + columnGap: theme.spacing.md, + rowGap: theme.spacing.xs, + }, + fullRow: { + gridColumnStart: 1, + gridColumnEnd: 4, + }, + chevron: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'transform 200ms ease', + + '&[data-rotate="true"]': { + transform: 'rotate(180deg)', + }, + }, +})); diff --git a/src/components/PermissionChooser.tsx b/src/components/PermissionChooser.tsx index b784ff338..26f90b4a8 100644 --- a/src/components/PermissionChooser.tsx +++ b/src/components/PermissionChooser.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; import uniqueId from 'lodash/uniqueId'; -import { EPermission, Permission, UserUtils, UserSession, userSession } from '../security'; +import { DefaultProps } from '@mantine/styles'; +import { Box, Button, ChevronIcon, Collapse, Text, Group, Radio, Stack, TextInput, SegmentedControl, Select } from '@mantine/core'; +import { EPermission, Permission, UserUtils, userSession } from '../security'; import { i18n } from '../i18n'; +import useStyles from './PermissionChooser.styles'; function PermissionsEntry({ permission, @@ -12,74 +15,77 @@ function PermissionsEntry({ setPermission: (permission: Permission) => void; setGetter: (permission: Permission) => Set; }) { - const id = React.useMemo(() => uniqueId('PermissionsEntry'), []); + let value: 'none' | 'read' | 'write' = 'none'; + + if (!setGetter(permission).has(EPermission.READ)) { + value = 'none'; + } else if (setGetter(permission).has(EPermission.READ) && !setGetter(permission).has(EPermission.WRITE)) { + value = 'read'; + } else if (setGetter(permission).has(EPermission.WRITE)) { + value = 'write'; + } return ( -
- { - const p = permission.clone(); - setGetter(p).clear(); - setPermission(p); - }} - /> - - { - const p = permission.clone(); - setGetter(p).clear(); - setGetter(p).add(EPermission.READ); - setPermission(p); - }} - /> - - { - const p = permission.clone(); - setGetter(p).clear(); - setGetter(p).add(EPermission.READ); - setGetter(p).add(EPermission.WRITE); - setPermission(p); - }} - /> - -
+ { + switch (newValue) { + case 'none': { + const p = permission.clone(); + setGetter(p).clear(); + setPermission(p); + break; + } + case 'read': { + const p = permission.clone(); + setGetter(p).clear(); + setGetter(p).add(EPermission.READ); + setPermission(p); + break; + } + case 'write': { + const p = permission.clone(); + setGetter(p).clear(); + setGetter(p).add(EPermission.READ); + setGetter(p).add(EPermission.WRITE); + setPermission(p); + break; + } + default: + break; + } + }} + data={[ + { + label: ( + <> + {i18n.t('visyn:permission.noPermission')} + + ), + value: 'none', + }, + { + label: ( + <> + {i18n.t('visyn:permission.read')} + + ), + value: 'read', + }, + { + label: ( + <> + {i18n.t('visyn:permission.write')} + + ), + value: 'write', + }, + ]} + /> ); } -export function PermissionChooser({ - permission, - buddies, - group, - setPermission, - setBuddies, - setGroup, - extra = null, -}: { +interface PermissionChooserProps extends DefaultProps { permission: Permission; buddies: string[]; group: string; @@ -87,104 +93,120 @@ export function PermissionChooser({ setBuddies: (buddies: string[]) => void; setGroup: (group: string) => void; extra?: React.ReactNode; -}) { - const id = React.useMemo(() => uniqueId('PermissionChooser'), []); - const user = userSession.currentUser(); - const roles = user ? user.roles : UserUtils.ANONYMOUS_USER.roles; - const [advancedOpen, setAdvancedOpen] = React.useState(false); +} - return ( - <> -
- { - const p = permission.clone(); - p.others.clear(); - setPermission(p); - }} - /> - -
-
- { - const p = permission.clone(); - p.others.clear(); - p.others.add(EPermission.READ); - setPermission(p); - }} - /> - -
- - - {extra} -
-
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - - - p.others} /> -
- -

{i18n.t('visyn:permission.definePermissions')}

- -
- - - p.group} /> -
- -

{i18n.t('visyn:permission.specifyRole')}

- -
- - setBuddies(e.currentTarget.value.split(';'))} - /> - p.buddies} /> -
-

{i18n.t('visyn:permission.buddiesDescription')}

-
- - ); -} + + + {i18n.t('visyn:permission.private')} + + } + /> + + {i18n.t('visyn:permission.publicMsg')} + + } + /> + + + + + + + {extra} + + + + {i18n.t('visyn:permission.public')} +
+ p.others} /> + + + {i18n.t('visyn:permission.definePermissions')} + + + {i18n.t('visyn:permission.group')} + + + + + `, + ); + + const filterMissing = findFilterMissing(node); + const input = node.querySelector('input[type="text"]'); + + this.enableLivePreviews([filterMissing, input]); + + if (!this.showLivePreviews()) { + return; + } + input.addEventListener( + 'input', + debounce(() => this.submit(), 100), + { + passive: true, + }, + ); + } +} diff --git a/src/ranking/smiles/SMILESRenderer.tsx b/src/ranking/smiles/SMILESRenderer.tsx new file mode 100644 index 000000000..355e39a38 --- /dev/null +++ b/src/ranking/smiles/SMILESRenderer.tsx @@ -0,0 +1,123 @@ +import { + ICellRendererFactory, + ERenderMode, + ICellRenderer, + IDataRow, + IRenderContext, + IGroupCellRenderer, + IOrderedGroup, + renderMissingDOM, + ISummaryRenderer, +} from 'lineupjs'; +import { abortAble } from 'lineupengine'; +import { SMILESColumn } from './SMILESColumn'; + +const template = '
'; + +function getImageURL(structure: string, substructure: string | null = null, align: string | null = null): string { + return `/api/rdkit/?structure=${encodeURIComponent(structure)}${substructure ? `&substructure=${encodeURIComponent(substructure)}` : ''}${ + align ? `&align=${encodeURIComponent(align)}` : '' + }`; +} + +async function fetchImage({ url, data, method }: { url: string; data?: any; method?: string }): Promise { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + }, + // mode: '*cors', // no-cors, *cors, same-origin + method, + redirect: 'follow', + ...(data + ? { + body: JSON.stringify(data), + } + : {}), + }); + if (!response.ok) { + throw Error((await response.json().catch(() => null))?.message || response.statusText); + } + return response.text(); +} + +async function getReducedImages(structures: string[]): Promise { + // maximum common substructure + if (structures.length > 2) { + return fetchImage({ url: '/api/rdkit/mcs/', data: structures, method: 'POST' }); + } + + // similarity + if (structures.length === 2) { + const reference = structures[0]; + const probe = structures.length > 1 ? structures[1] : structures[0]; + return fetchImage({ url: `/api/rdkit/similarity/?structure=${encodeURIComponent(probe)}&reference=${encodeURIComponent(reference)}`, method: 'GET' }); + } + + // single = first structure + return fetchImage({ url: `/api/rdkit/?structure=${encodeURIComponent(structures[0])}`, method: 'GET' }); +} + +function svgToImageSrc(svg: string): string { + return `data:image/svg+xml;base64,${btoa(svg)}`; +} + +function svgToCSSBackground(svg: string): string { + return `url('${svgToImageSrc(svg)}')`; +} + +export class SMILESRenderer implements ICellRendererFactory { + readonly title: string = 'SMILES'; + + canRender(col: SMILESColumn, mode: ERenderMode): boolean { + return col instanceof SMILESColumn && (mode === ERenderMode.CELL || mode === ERenderMode.GROUP); + } + + create(col: SMILESColumn): ICellRenderer { + return { + template, + update: (n: HTMLLinkElement, d: IDataRow) => { + if (!renderMissingDOM(n, col, d)) { + if (d.v.images?.[0]) { + n.style.backgroundImage = svgToCSSBackground(d.v.images[0]); + return null; + } + const value = col?.getValue(d); + // Load aysnc to avoid triggering + return abortAble( + new Promise((resolve) => { + window.setTimeout(() => resolve(value), 500); + }), + ).then((image) => { + if (typeof image === 'symbol') { + return; + } + n.style.backgroundImage = `url('${getImageURL(value, col.getFilter()?.filter, col.getAlign())}')`; + n.title = value; + }); + } + return null; + }, + }; + } + + createGroup(col: SMILESColumn, context: IRenderContext): IGroupCellRenderer { + return { + template, + update: (n: HTMLImageElement, group: IOrderedGroup) => { + context.tasks.groupRows(col, group, 'SMILESRendererGroup', (rows) => { + return abortAble(getReducedImages(Array.from(rows.map((row) => col.getLabel(row))))).then((res: any) => { + n.style.backgroundImage = res ? svgToCSSBackground(res) : ''; + }); + }); + }, + }; + } + + createSummary(): ISummaryRenderer { + // no renderer + return { + template: `
`, + update: () => {}, + }; + } +} diff --git a/src/ranking/smiles/index.ts b/src/ranking/smiles/index.ts new file mode 100644 index 000000000..dd42bc417 --- /dev/null +++ b/src/ranking/smiles/index.ts @@ -0,0 +1,5 @@ +export * from './SMILESColumn'; +export * from './SMILESColumnBuilder'; +export * from './SMILESFilterDialog'; +export * from './SMILESRenderer'; +export * from './utils'; diff --git a/src/ranking/smiles/utils.ts b/src/ranking/smiles/utils.ts new file mode 100644 index 000000000..2e80c308e --- /dev/null +++ b/src/ranking/smiles/utils.ts @@ -0,0 +1,73 @@ +import { ALineUp, Column, DataBuilder, IRankingHeaderContext, LocalDataProvider, defaultOptions, dialogContext } from 'lineupjs'; +import { uniqueId } from 'lodash'; +import { SMILESColumn } from './SMILESColumn'; +import { SMILESRenderer } from './SMILESRenderer'; +import { SMILESFilterDialog } from './SMILESFilterDialog'; + +export function registerSMILESColumn(builder: DataBuilder, { setDynamicHeight = false }: { setDynamicHeight?: boolean } = {}) { + builder.registerColumnType('smiles', SMILESColumn); + builder.registerRenderer('smiles', new SMILESRenderer()); + builder.registerToolbarAction( + 'filterSMILES', + // TODO remove default string filter; use `filterString` as key would override the default string filter, but also for string columns + { + title: 'Filter …', // the toolbar icon is derived from this string! (= transformed to `lu-action-filter`) + onClick: (col: SMILESColumn, evt: MouseEvent, ctx: IRankingHeaderContext, level: number, viaShortcut: boolean) => { + const dialog = new SMILESFilterDialog(col, dialogContext(ctx, level, evt), ctx); + dialog.open(); + }, + options: { + mode: 'menu+shortcut', + featureCategory: 'ranking', + featureLevel: 'basic', + }, + }, + ); + + if (setDynamicHeight) { + builder.dynamicHeight((d, ranking) => { + const DEFAULT_ROW_HEIGHT = defaultOptions().rowHeight; // LineUp default rowHeight = 18 + + // get list of smiles columns from the current ranking + const smilesColumns = ranking.children.filter((col) => col instanceof SMILESColumn); + + if (smilesColumns.length === 0) { + return { defaultHeight: DEFAULT_ROW_HEIGHT, height: () => DEFAULT_ROW_HEIGHT, padding: () => 0 }; + } + + const maxColumnHeight = Math.max(DEFAULT_ROW_HEIGHT, ...smilesColumns.map((col) => col.getWidth())); // squared image -> use col width as height + + return { + defaultHeight: maxColumnHeight, + height: () => maxColumnHeight, + padding: () => 0, + }; + }); + } +} + +export function autosizeWithSMILESColumn({ provider, lineup }: { provider: LocalDataProvider; lineup: ALineUp }) { + const uid = uniqueId('smiles-column'); + + const addWidthChangedListener = (col: Column) => { + if (col instanceof SMILESColumn) { + col.on(`${Column.EVENT_WIDTH_CHANGED}.${uid}`, () => { + // trigger a re-render of LineUp using the new calculated row height in `dynamicHeight()` + lineup.update(); + }); + } + }; + + // Add width changed listener for new smiles columns + provider.on(`${LocalDataProvider.EVENT_ADD_COLUMN}.${uid}`, (col) => addWidthChangedListener(col)); + + // And remove it again when the column is removed + provider.on(`${LocalDataProvider.EVENT_REMOVE_COLUMN}.${uid}`, (col) => { + if (col instanceof SMILESColumn) { + col.on(`${Column.EVENT_WIDTH_CHANGED}.${uid}`, null); // remove event listener when column is removed + } + }); + + // Add width changed listener for existing smiles columns + provider.getRankings().forEach((ranking) => ranking.flatColumns.forEach((col) => addWidthChangedListener(col))); +}