diff --git a/packages/ketcher-core/src/application/editor/MacromoleculesConverter.ts b/packages/ketcher-core/src/application/editor/MacromoleculesConverter.ts index 164a3113a1..4fa2aac34b 100644 --- a/packages/ketcher-core/src/application/editor/MacromoleculesConverter.ts +++ b/packages/ketcher-core/src/application/editor/MacromoleculesConverter.ts @@ -467,6 +467,13 @@ export class MacromoleculesConverter { monomer, atomId, atom.label as AtomLabel, + { + charge: atom.charge, + explicitValence: atom.explicitValence, + isotope: atom.isotope, + radical: atom.radical, + alias: atom.alias, + }, ); command.merge(atomAddCommand); diff --git a/packages/ketcher-core/src/application/render/renderers/AtomRenderer.ts b/packages/ketcher-core/src/application/render/renderers/AtomRenderer.ts index 9904984d86..d83ebcba6f 100644 --- a/packages/ketcher-core/src/application/render/renderers/AtomRenderer.ts +++ b/packages/ketcher-core/src/application/render/renderers/AtomRenderer.ts @@ -4,10 +4,12 @@ import { Coordinates } from 'application/editor/shared/coordinates'; import { CoreEditor, ZoomTool } from 'application/editor'; import { AtomLabel, ElementColor, Elements } from 'domain/constants'; import { D3SvgElementSelection } from 'application/render/types'; +import { VALENCE_MAP } from 'application/render/restruct/constants'; export class AtomRenderer extends BaseRenderer { private selectionElement?: D3SvgElementSelection; private textElement?: D3SvgElementSelection; + private radicalElement?: D3SvgElementSelection; constructor(public atom: Atom) { super(atom); @@ -58,7 +60,10 @@ export class AtomRenderer extends BaseRenderer { } private appendSelectionContour() { - if (this.labelLength < 2 || !this.isLabelVisible) { + if ( + (this.labelLength < 2 || !this.isLabelVisible) && + !this.atom.hasCharge + ) { return this.rootElement ?.insert('circle', ':first-child') .attr('r', 10) @@ -134,6 +139,10 @@ export class AtomRenderer extends BaseRenderer { return false; } + public get labelText() { + return this.atom.properties.alias || this.atom.label; + } + public get isLabelVisible() { const editor = CoreEditor.provideEditorInstance(); const viewModel = editor.viewModel; @@ -143,19 +152,41 @@ export class AtomRenderer extends BaseRenderer { const isAtomTerminal = !atomNeighborsHalfEdges?.length || atomNeighborsHalfEdges.length === 1; const isAtomInMiddleOfChain = (atomNeighborsHalfEdges?.length || 0) >= 2; - - if (isCarbon && !isAtomTerminal) { + const hasCharge = this.atom.hasCharge; + const hasRadical = this.atom.hasRadical; + const hasAlias = this.atom.properties.alias; + const hasExplicitValence = this.atom.hasExplicitValence; + const hasExplicitIsotope = this.atom.hasExplicitIsotope; + + if ( + isCarbon && + !isAtomTerminal && + !hasCharge && + !hasRadical && + !hasAlias && + !hasExplicitValence && + !hasExplicitIsotope + ) { return false; } - if ((isAtomTerminal && visibleTerminal) || isAtomInMiddleOfChain) + if ((isAtomTerminal && visibleTerminal) || isAtomInMiddleOfChain) { return true; + } return false; } public get labelLength() { - const { hydrogenAmount } = this.atom.calculateValence(); + let { hydrogenAmount } = this.atom.calculateValence(); + + if (this.labelText.length > 1) { + return this.labelText.length; + } + + if (!this.shouldDisplayHydrogen) { + hydrogenAmount = 0; + } if (hydrogenAmount === 0) { return 1; @@ -164,18 +195,54 @@ export class AtomRenderer extends BaseRenderer { return hydrogenAmount === 1 ? 2 : 3; } + private get labelColor() { + return this.atom.properties.alias ? 'black' : ElementColor[this.atom.label]; + } + + public get labelBBoxes() { + // TODO calculate label bboxes after creation of text element + // and store them in atom renderer to optimize performance + if (!this.textElement) { + return []; + } + + const labelBboxes: DOMRect[] = []; + const radicalElementBbox = this.radicalElement?.node()?.getBBox(); + + this.textElement + .selectAll('tspan') + .each(function (_atomRenderer, tspanIndex, tspans) { + labelBboxes.push(tspans[tspanIndex].getBBox()); + }); + + if (radicalElementBbox) { + labelBboxes.push(radicalElementBbox); + } + + return labelBboxes; + } + + public get shouldDisplayHydrogen() { + // Remove when rules for displaying hydrogen are implemented same as in molecules mode + return this.atom.label !== AtomLabel.C; + } + private appendLabel() { if (!this.isLabelVisible) { return; } - const { hydrogenAmount } = this.atom.calculateValence(); + let { hydrogenAmount } = this.atom.calculateValence(); const shouldHydrogenBeOnLeft = this.shouldHydrogenBeOnLeft; + if (!this.shouldDisplayHydrogen) { + hydrogenAmount = 0; + } + const textElement = this.rootElement ?.append('text') .attr('y', 5) - .attr('fill', ElementColor[this.atom.label]) + .attr('fill', this.labelColor) .attr( 'style', 'user-select: none; font-family: Arial; letter-spacing: 1.2px;', @@ -184,11 +251,17 @@ export class AtomRenderer extends BaseRenderer { .attr('pointer-events', 'none'); if (!shouldHydrogenBeOnLeft) { - textElement?.append('tspan').text(this.atom.label); + textElement + ?.append('tspan') + .attr('dy', this.atom.hasExplicitIsotope ? 4 : 0) + .text(this.atom.properties.alias || this.atom.label); } if (hydrogenAmount > 0) { - textElement?.append('tspan').text('H'); + textElement + ?.append('tspan') + .attr('dy', this.atom.hasExplicitIsotope ? 4 : 0) + .text('H'); } if (hydrogenAmount > 1) { @@ -198,7 +271,7 @@ export class AtomRenderer extends BaseRenderer { if (shouldHydrogenBeOnLeft) { textElement ?.append('tspan') - .text(this.atom.label) + .text(this.atom.properties.alias || this.atom.label) .attr('dy', hydrogenAmount > 1 ? -3 : 0); } @@ -255,10 +328,102 @@ export class AtomRenderer extends BaseRenderer { this.move(); } + private appendCharge() { + if (this.atom.hasCharge) { + const charge = this.atom.properties.charge as number; + + this.textElement + ?.append('tspan') + .text( + (Math.abs(charge) > 1 ? Math.abs(charge) : '') + + (charge > 0 ? '+' : '-'), + ) + .attr('fill', this.labelColor) + .attr('dy', -4); + } + } + + private appendRadical() { + if (!this.atom.hasRadical) { + return; + } + + const radical = this.atom.properties.radical; + + this.radicalElement = this.rootElement?.append('g'); + + switch (radical) { + case 1: + this.radicalElement + ?.append('circle') + .attr('cx', 3) + .attr('cy', -10) + .attr('r', 2) + .attr('fill', this.labelColor); + this.radicalElement + ?.append('circle') + .attr('cx', -3) + .attr('cy', -10) + .attr('r', 2) + .attr('fill', this.labelColor); + break; + case 2: + this.radicalElement + ?.append('circle') + .attr('cx', 0) + .attr('cy', -10) + .attr('r', 2) + .attr('fill', this.labelColor); + break; + case 3: + this.radicalElement + ?.append('path') + .attr('d', `M 0 -5 L 2 -10 L 4 -5 M -6 -5 L -4 -10 L -2 -5`) + .attr('fill', 'none') + .attr('stroke', this.labelColor) + .attr('stroke-width', 1.4); + break; + } + } + + private appendExplicitValence() { + if (this.atom.hasExplicitValence) { + const explicitValence = this.atom.properties.explicitValence as number; + + this.textElement + ?.append('tspan') + .text(`(${VALENCE_MAP[explicitValence]})`) + .attr('fill', this.labelColor) + .attr('letter-spacing', 0.2) + .attr('dy', -4); + } + } + + private appendExplicitIsotope() { + if (this.atom.hasExplicitIsotope) { + const explicitIsotope = this.atom.properties.isotope as number; + + this.textElement + ?.insert('tspan', ':first-child') + .text(explicitIsotope) + .attr('fill', this.labelColor) + .attr('letter-spacing', 0.2) + .attr('dy', -4); + } + } + + private appendAtomProperties() { + this.appendExplicitIsotope(); + this.appendCharge(); + this.appendRadical(); + this.appendExplicitValence(); + } + show() { this.rootElement = this.appendRootElement(); this.bodyElement = this.appendBody(); this.textElement = this.appendLabel(); + this.appendAtomProperties(); this.hoverElement = this.appendHover(); this.drawSelection(); } diff --git a/packages/ketcher-core/src/application/render/renderers/BondRenderer.ts b/packages/ketcher-core/src/application/render/renderers/BondRenderer.ts index c7a37e7ae9..d4d645fd37 100644 --- a/packages/ketcher-core/src/application/render/renderers/BondRenderer.ts +++ b/packages/ketcher-core/src/application/render/renderers/BondRenderer.ts @@ -3,13 +3,14 @@ import { Atom } from 'domain/entities/CoreAtom'; import { Coordinates } from 'application/editor/shared/coordinates'; import { Bond } from 'domain/entities/CoreBond'; import { Scale } from 'domain/helpers'; -import { Vec2 } from 'domain/entities'; +import { Box2Abs, Vec2 } from 'domain/entities'; import { getBondLineShift } from 'application/render/restruct/rebond'; import { CoreEditor } from 'application/editor'; import { HalfEdge } from 'application/render/view-model/HalfEdge'; import { ViewModel } from 'application/render/view-model/ViewModel'; import { KetcherLogger } from 'utilities'; import { D3SvgElementSelection } from 'application/render/types'; +import util from 'application/render/util'; type MouseActionType = 'mouseover' | 'mouseenter'; @@ -140,14 +141,35 @@ export class BondRenderer extends BaseRenderer { atom: Atom, halfEdge: HalfEdge, ) { - if (atom.renderer?.isLabelVisible) { - return position.addScaled( - halfEdge.direction, - BOND_WIDTH * 3 + (this.bond.firstAtom.renderer?.labelLength || 0) * 4, - ); + if (!atom.renderer || !atom.renderer.isLabelVisible) { + return position; } - return position; + const atomLabelBBoxes = atom.renderer?.labelBBoxes; + const atomPositionInPixels = atom.renderer.scaledPosition; + let shiftValue = 0; + + atomLabelBBoxes?.forEach((labelSymbolBBox) => { + const relativeLabelSymbolBox2Abs = new Box2Abs( + labelSymbolBBox.x, + labelSymbolBBox.y, + labelSymbolBBox.x + labelSymbolBBox.width, + labelSymbolBBox.y + labelSymbolBBox.height, + ); + const absoluteLabelSymbolBox2Abs = + relativeLabelSymbolBox2Abs.translate(atomPositionInPixels); + + shiftValue = Math.max( + shiftValue, + util.shiftRayBox( + atomPositionInPixels, + halfEdge.direction, + absoluteLabelSymbolBox2Abs, + ), + ); + }); + + return position.addScaled(halfEdge.direction, BOND_WIDTH + shiftValue); } public appendSelection() { @@ -475,41 +497,52 @@ export class BondRenderer extends BaseRenderer { ); if (shift > 0) { - firstLineStartPosition = firstLineStartPosition.addScaled( - firstHalfEdge.direction, - bondSpace * - getBondLineShift( - firstHalfEdge.cosToRightNeighborHalfEdge, - firstHalfEdge.sinToRightNeighborHalfEdge, - ), - ); - - firstLineEndPosition = firstLineEndPosition.addScaled( - firstHalfEdge.direction, - -bondSpace * - getBondLineShift( - secondHalfEdge.cosToLeftNeighborHalfEdge, - secondHalfEdge.sinToLeftNeighborHalfEdge, - ), - ); + firstLineStartPosition = firstHalfEdge.firstAtom.renderer + ?.isLabelVisible + ? firstLineStartPosition + : firstLineStartPosition.addScaled( + firstHalfEdge.direction, + bondSpace * + getBondLineShift( + firstHalfEdge.cosToRightNeighborHalfEdge, + firstHalfEdge.sinToRightNeighborHalfEdge, + ), + ); + + firstLineEndPosition = firstHalfEdge.secondAtom.renderer?.isLabelVisible + ? firstLineEndPosition + : firstLineEndPosition.addScaled( + firstHalfEdge.direction, + -bondSpace * + getBondLineShift( + secondHalfEdge.cosToLeftNeighborHalfEdge, + secondHalfEdge.sinToLeftNeighborHalfEdge, + ), + ); } else if (shift < 0) { - secondLineStartPosition = secondLineStartPosition.addScaled( - firstHalfEdge.direction, - bondSpace * - getBondLineShift( - firstHalfEdge.cosToLeftNeighborHalfEdge, - firstHalfEdge.sinToLeftNeighborHalfEdge, - ), - ); - - secondLineEndPosition = secondLineEndPosition.addScaled( - firstHalfEdge.direction, - -bondSpace * - getBondLineShift( - secondHalfEdge.cosToRightNeighborHalfEdge, - secondHalfEdge.sinToRightNeighborHalfEdge, - ), - ); + secondLineStartPosition = firstHalfEdge.firstAtom.renderer + ?.isLabelVisible + ? secondLineStartPosition + : secondLineStartPosition.addScaled( + firstHalfEdge.direction, + bondSpace * + getBondLineShift( + firstHalfEdge.cosToLeftNeighborHalfEdge, + firstHalfEdge.sinToLeftNeighborHalfEdge, + ), + ); + + secondLineEndPosition = firstHalfEdge.secondAtom.renderer + ?.isLabelVisible + ? secondLineEndPosition + : secondLineEndPosition.addScaled( + firstHalfEdge.direction, + -bondSpace * + getBondLineShift( + secondHalfEdge.cosToRightNeighborHalfEdge, + secondHalfEdge.sinToRightNeighborHalfEdge, + ), + ); } this.pathShape = ` diff --git a/packages/ketcher-core/src/application/render/restruct/constants.ts b/packages/ketcher-core/src/application/render/restruct/constants.ts new file mode 100644 index 0000000000..3b52046e3f --- /dev/null +++ b/packages/ketcher-core/src/application/render/restruct/constants.ts @@ -0,0 +1,17 @@ +export const VALENCE_MAP = { + 0: '0', + 1: 'I', + 2: 'II', + 3: 'III', + 4: 'IV', + 5: 'V', + 6: 'VI', + 7: 'VII', + 8: 'VIII', + 9: 'IX', + 10: 'X', + 11: 'XI', + 12: 'XII', + 13: 'XIII', + 14: 'XIV', +}; diff --git a/packages/ketcher-core/src/application/render/restruct/reatom.ts b/packages/ketcher-core/src/application/render/restruct/reatom.ts index 0c39f40454..e5401865d2 100644 --- a/packages/ketcher-core/src/application/render/restruct/reatom.ts +++ b/packages/ketcher-core/src/application/render/restruct/reatom.ts @@ -46,6 +46,7 @@ import { import { MonomerMicromolecule } from 'domain/entities/monomerMicromolecule'; import { attachmentPointNames } from 'domain/types'; import { getAttachmentPointLabel } from 'domain/helpers/attachmentPointCalculations'; +import { VALENCE_MAP } from 'application/render/restruct/constants'; interface ElemAttr { text: string; @@ -1104,28 +1105,11 @@ function showExplicitValence( render: Render, rightMargin: number, ): ElemAttr { - const mapValence = { - 0: '0', - 1: 'I', - 2: 'II', - 3: 'III', - 4: 'IV', - 5: 'V', - 6: 'VI', - 7: 'VII', - 8: 'VIII', - 9: 'IX', - 10: 'X', - 11: 'XI', - 12: 'XII', - 13: 'XIII', - 14: 'XIV', - }; const ps = Scale.modelToCanvas(atom.a.pp, render.options); const options = render.options; const delta = 0.5 * options.lineWidth; const valence: any = {}; - valence.text = mapValence[atom.a.explicitValence]; + valence.text = VALENCE_MAP[atom.a.explicitValence]; if (!valence.text) { throw new Error('invalid valence ' + atom.a.explicitValence.toString()); } diff --git a/packages/ketcher-core/src/domain/entities/CoreAtom.ts b/packages/ketcher-core/src/domain/entities/CoreAtom.ts index dc37b9771c..8a17f66d0e 100644 --- a/packages/ketcher-core/src/domain/entities/CoreAtom.ts +++ b/packages/ketcher-core/src/domain/entities/CoreAtom.ts @@ -6,7 +6,15 @@ import { Bond as MicromoleculesBond } from 'domain/entities/bond'; import { BaseRenderer } from 'application/render'; import { AtomLabel, Elements } from 'domain/constants'; import { AtomRenderer } from 'application/render/renderers/AtomRenderer'; +import { isNumber } from 'lodash'; +export interface AtomProperties { + charge?: number | null; + explicitValence?: number; + isotope?: number | null; + radical?: number; + alias?: string | null; +} export class Atom extends DrawingEntity { public bonds: Bond[] = []; public renderer: AtomRenderer | undefined = undefined; @@ -15,6 +23,7 @@ export class Atom extends DrawingEntity { public monomer: BaseMonomer, public atomIdInMicroMode, public label: AtomLabel, + public properties: AtomProperties = {}, ) { super(position); } @@ -32,6 +41,10 @@ export class Atom extends DrawingEntity { super.setBaseRenderer(renderer as BaseRenderer); } + public get isCarbon() { + return this.label === AtomLabel.C; + } + private calculateConnections() { let connectionsAmount = 0; @@ -61,12 +74,23 @@ export class Atom extends DrawingEntity { return connectionsAmount; } - private calculateCharge() { - return 0; + public get hasRadical() { + return isNumber(this.properties.radical) && this.properties.radical !== 0; + } + + public get hasCharge() { + return isNumber(this.properties.charge) && this.properties.charge !== 0; + } + + public get hasExplicitValence() { + return ( + isNumber(this.properties.explicitValence) && + this.properties.explicitValence !== -1 + ); } - private calculateRadicalAmount() { - return 0; + public get hasExplicitIsotope() { + return isNumber(this.properties.isotope) && this.properties.isotope >= 0; } calculateValence() { @@ -74,9 +98,9 @@ export class Atom extends DrawingEntity { const element = Elements.get(label); const elementGroupNumber = element?.group; const connectionAmount = this.calculateConnections(); - const radicalAmount = this.calculateRadicalAmount(); + const radicalAmount = this.properties.radical || 0; const absCharge = 0; - const charge = this.calculateCharge(); + const charge = this.properties.charge || 0; let valence = this.calculateConnections(); let hydrogenAmount = 0; diff --git a/packages/ketcher-core/src/domain/entities/DrawingEntitiesManager.ts b/packages/ketcher-core/src/domain/entities/DrawingEntitiesManager.ts index 1eff5830d4..d5c8573b9a 100644 --- a/packages/ketcher-core/src/domain/entities/DrawingEntitiesManager.ts +++ b/packages/ketcher-core/src/domain/entities/DrawingEntitiesManager.ts @@ -68,7 +68,7 @@ import { Matrix } from 'domain/entities/canvas-matrix/Matrix'; import { Cell } from 'domain/entities/canvas-matrix/Cell'; import { AmbiguousMonomer } from 'domain/entities/AmbiguousMonomer'; import { KetMonomerClass } from 'application/formatters'; -import { Atom } from 'domain/entities/CoreAtom'; +import { Atom, AtomProperties } from 'domain/entities/CoreAtom'; import { Bond } from 'domain/entities/CoreBond'; import { AtomAddOperation, @@ -2369,6 +2369,7 @@ export class DrawingEntitiesManager { monomer: BaseMonomer, atomIdInMicroMode: number, label: AtomLabel, + properties?: AtomProperties, _atom?: Atom, ) { if (_atom) { @@ -2377,7 +2378,13 @@ export class DrawingEntitiesManager { return _atom; } - const atom = new Atom(position, monomer, atomIdInMicroMode, label); + const atom = new Atom( + position, + monomer, + atomIdInMicroMode, + label, + properties, + ); this.atoms.set(atom.id, atom); @@ -2389,16 +2396,19 @@ export class DrawingEntitiesManager { monomer: BaseMonomer, atomIdInMicroMode: number, label: AtomLabel, + properties?: AtomProperties, ) { const command = new Command(); const atomAddOperation = new AtomAddOperation( - this.addAtomChangeModel.bind( - this, - position, - monomer, - atomIdInMicroMode, - label, - ), + (atom?: Atom) => + this.addAtomChangeModel( + position, + monomer, + atomIdInMicroMode, + label, + properties, + atom, + ), this.deleteAtomChangeModel.bind(this), ); @@ -2420,13 +2430,15 @@ export class DrawingEntitiesManager { new AtomDeleteOperation( atom, this.deleteAtomChangeModel.bind(this, atom), - this.addAtomChangeModel.bind( - this, - atom.position, - atom.monomer, - atom.atomIdInMicroMode, - atom.label, - ), + () => + this.addAtomChangeModel( + atom.position, + atom.monomer, + atom.atomIdInMicroMode, + atom.label, + atom.properties, + atom, + ), ), );