From bdee4859f2aaa89bd59f2daf4ac1fb43e37016ca Mon Sep 17 00:00:00 2001 From: Alexander Rose <alexander.rose@weirdbyte.de> Date: Sat, 14 Jan 2023 12:39:25 -0800 Subject: [PATCH] generic loci support for overpaint, substance, clipping --- CHANGELOG.md | 1 + .../helpers/structure-clipping.ts | 4 +- .../helpers/structure-overpaint.ts | 4 +- .../helpers/structure-substance.ts | 4 +- src/mol-theme/clipping.ts | 103 ++++++++------- src/mol-theme/overpaint.ts | 111 +++++++++------- src/mol-theme/substance.ts | 123 ++++++++++-------- 7 files changed, 197 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeac88994..05b18f627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Note that since we don't clearly distinguish between a public and private interf - Improve checks in in UnitsRepresentation setVisualState - Add StructureElement.Loci.forEachLocation - Add RepresentationRegistry.clear and ThemeRegistry.clear +- Add generic Loci support for overpaint, substance, clipping themes ## [v3.28.0] - 2022-12-20 diff --git a/src/mol-plugin-state/helpers/structure-clipping.ts b/src/mol-plugin-state/helpers/structure-clipping.ts index 5248f9f59..8b0e90672 100644 --- a/src/mol-plugin-state/helpers/structure-clipping.ts +++ b/src/mol-plugin-state/helpers/structure-clipping.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> * @author David Sehnal <david.sehnal@gmail.com> @@ -61,5 +61,5 @@ async function eachRepr(plugin: PluginContext, components: StructureComponentRef function getFilteredBundle(layers: Clipping.BundleLayer[], structure: Structure) { const clipping = Clipping.ofBundle(layers, structure.root); const merged = Clipping.merge(clipping); - return Clipping.filter(merged, structure); + return Clipping.filter(merged, structure) as Clipping<StructureElement.Loci>; } \ No newline at end of file diff --git a/src/mol-plugin-state/helpers/structure-overpaint.ts b/src/mol-plugin-state/helpers/structure-overpaint.ts index 0cfbf3fd7..ef7d22efa 100644 --- a/src/mol-plugin-state/helpers/structure-overpaint.ts +++ b/src/mol-plugin-state/helpers/structure-overpaint.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> * @author David Sehnal <david.sehnal@gmail.com> @@ -72,5 +72,5 @@ async function eachRepr(plugin: PluginContext, components: StructureComponentRef function getFilteredBundle(layers: Overpaint.BundleLayer[], structure: Structure) { const overpaint = Overpaint.ofBundle(layers, structure.root); const merged = Overpaint.merge(overpaint); - return Overpaint.filter(merged, structure); + return Overpaint.filter(merged, structure) as Overpaint<StructureElement.Loci>; } \ No newline at end of file diff --git a/src/mol-plugin-state/helpers/structure-substance.ts b/src/mol-plugin-state/helpers/structure-substance.ts index 63d819be4..33f23739e 100644 --- a/src/mol-plugin-state/helpers/structure-substance.ts +++ b/src/mol-plugin-state/helpers/structure-substance.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2021-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> * @author David Sehnal <david.sehnal@gmail.com> @@ -72,5 +72,5 @@ async function eachRepr(plugin: PluginContext, components: StructureComponentRef function getFilteredBundle(layers: Substance.BundleLayer[], structure: Structure) { const substance = Substance.ofBundle(layers, structure.root); const merged = Substance.merge(substance); - return Substance.filter(merged, structure); + return Substance.filter(merged, structure) as Substance<StructureElement.Loci>; } \ No newline at end of file diff --git a/src/mol-theme/clipping.ts b/src/mol-theme/clipping.ts index bbb22aea3..af76345d5 100644 --- a/src/mol-theme/clipping.ts +++ b/src/mol-theme/clipping.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2020-2021 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2020-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> */ @@ -11,17 +11,18 @@ import { BitFlags } from '../mol-util/bit-flags'; export { Clipping }; -type Clipping = { - readonly layers: ReadonlyArray<Clipping.Layer> +type Clipping<T extends Loci = Loci> = { + readonly kind: T['kind'] + readonly layers: ReadonlyArray<Clipping.Layer<T>> } -function Clipping(layers: Clipping['layers']): Clipping { - return { layers }; +function Clipping<T extends Loci>(kind: T['kind'], layers: ReadonlyArray<Clipping.Layer<T>>): Clipping<T> { + return { kind, layers }; } namespace Clipping { - export type Layer = { readonly loci: StructureElement.Loci, readonly groups: Groups } - export const Empty: Clipping = { layers: [] }; + export type Layer<T extends Loci = Loci> = { readonly loci: T, readonly groups: Groups } + export const Empty: Clipping = { kind: 'empty-loci', layers: [] }; export type Groups = BitFlags<Groups.Flag> export namespace Groups { @@ -101,57 +102,69 @@ namespace Clipping { /** Remap layers */ export function remap(clipping: Clipping, structure: Structure): Clipping { - const layers: Clipping.Layer[] = []; - for (const layer of clipping.layers) { - let { loci, groups } = layer; - loci = StructureElement.Loci.remap(loci, structure); - if (!StructureElement.Loci.isEmpty(loci)) { - layers.push({ loci, groups }); + if (clipping.kind === 'element-loci') { + const layers: Clipping.Layer[] = []; + for (const layer of clipping.layers) { + let { loci, groups } = layer; + loci = StructureElement.Loci.remap(loci as StructureElement.Loci, structure); + if (!StructureElement.Loci.isEmpty(loci)) { + layers.push({ loci, groups }); + } } + return { kind: 'element-loci', layers }; + } else { + return clipping; } - return { layers }; } /** Merge layers */ export function merge(clipping: Clipping): Clipping { if (isEmpty(clipping)) return clipping; - const { structure } = clipping.layers[0].loci; - const map = new Map<Groups, StructureElement.Loci>(); - let shadowed = StructureElement.Loci.none(structure); - for (let i = 0, il = clipping.layers.length; i < il; ++i) { - let { loci, groups } = clipping.layers[il - i - 1]; // process from end - loci = StructureElement.Loci.subtract(loci, shadowed); - shadowed = StructureElement.Loci.union(loci, shadowed); - if (!StructureElement.Loci.isEmpty(loci)) { - if (map.has(groups)) { - loci = StructureElement.Loci.union(loci, map.get(groups)!); + if (clipping.kind === 'element-loci') { + const { structure } = clipping.layers[0].loci as StructureElement.Loci; + const map = new Map<Groups, StructureElement.Loci>(); + let shadowed = StructureElement.Loci.none(structure); + for (let i = 0, il = clipping.layers.length; i < il; ++i) { + let { loci, groups } = clipping.layers[il - i - 1]; // process from end + loci = StructureElement.Loci.subtract(loci as StructureElement.Loci, shadowed); + shadowed = StructureElement.Loci.union(loci, shadowed); + if (!StructureElement.Loci.isEmpty(loci)) { + if (map.has(groups)) { + loci = StructureElement.Loci.union(loci, map.get(groups)!); + } + map.set(groups, loci); } - map.set(groups, loci); } + const layers: Clipping.Layer[] = []; + map.forEach((loci, groups) => { + layers.push({ loci, groups }); + }); + return { kind: 'element-loci', layers }; + } else { + return clipping; } - const layers: Clipping.Layer[] = []; - map.forEach((loci, groups) => { - layers.push({ loci, groups }); - }); - return { layers }; } /** Filter layers */ export function filter(clipping: Clipping, filter: Structure): Clipping { if (isEmpty(clipping)) return clipping; - const { structure } = clipping.layers[0].loci; - const layers: Clipping.Layer[] = []; - for (const layer of clipping.layers) { - let { loci, groups } = layer; - // filter by first map to the `filter` structure and - // then map back to the original structure of the clipping loci - const filtered = StructureElement.Loci.remap(loci, filter); - loci = StructureElement.Loci.remap(filtered, structure); - if (!StructureElement.Loci.isEmpty(loci)) { - layers.push({ loci, groups }); + if (clipping.kind === 'element-loci') { + const { structure } = clipping.layers[0].loci as StructureElement.Loci; + const layers: Clipping.Layer[] = []; + for (const layer of clipping.layers) { + let { loci, groups } = layer; + // filter by first map to the `filter` structure and + // then map back to the original structure of the clipping loci + const filtered = StructureElement.Loci.remap(loci as StructureElement.Loci, filter); + loci = StructureElement.Loci.remap(filtered, structure); + if (!StructureElement.Loci.isEmpty(loci)) { + layers.push({ loci, groups }); + } } + return { kind: 'element-loci', layers }; + } else { + return clipping; } - return { layers }; } export type ScriptLayer = { script: Script, groups: Groups } @@ -164,7 +177,7 @@ namespace Clipping { layers.push({ loci, groups }); } } - return { layers }; + return { kind: 'element-loci', layers }; } export type BundleLayer = { bundle: StructureElement.Bundle, groups: Groups } @@ -175,16 +188,16 @@ namespace Clipping { const loci = StructureElement.Bundle.toLoci(bundle, structure.root); layers.push({ loci, groups }); } - return { layers }; + return { kind: 'element-loci', layers }; } - export function toBundle(clipping: Clipping) { + export function toBundle(clipping: Clipping<StructureElement.Loci>) { const layers: BundleLayer[] = []; for (let i = 0, il = clipping.layers.length; i < il; ++i) { const { loci, groups } = clipping.layers[i]; const bundle = StructureElement.Bundle.fromLoci(loci); layers.push({ bundle, groups }); } - return { layers }; + return { kind: 'element-loci', layers }; } } \ No newline at end of file diff --git a/src/mol-theme/overpaint.ts b/src/mol-theme/overpaint.ts index 027b6150f..123f36a71 100644 --- a/src/mol-theme/overpaint.ts +++ b/src/mol-theme/overpaint.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> */ @@ -11,15 +11,18 @@ import { Script } from '../mol-script/script'; export { Overpaint }; -type Overpaint = { readonly layers: ReadonlyArray<Overpaint.Layer> } +type Overpaint<T extends Loci = Loci> = { + readonly kind: T['kind'] + readonly layers: ReadonlyArray<Overpaint.Layer<T>> +} -function Overpaint(layers: ReadonlyArray<Overpaint.Layer>): Overpaint { - return { layers }; +function Overpaint<T extends Loci>(kind: T['kind'], layers: ReadonlyArray<Overpaint.Layer<T>>): Overpaint<T> { + return { kind, layers }; } namespace Overpaint { - export type Layer = { readonly loci: StructureElement.Loci, readonly color: Color, readonly clear: boolean } - export const Empty: Overpaint = { layers: [] }; + export type Layer<T extends Loci = Loci> = { readonly loci: T, readonly color: Color, readonly clear: boolean } + export const Empty: Overpaint = { kind: 'empty-loci', layers: [] }; export function areEqual(oA: Overpaint, oB: Overpaint) { if (oA.layers.length === 0 && oB.layers.length === 0) return true; @@ -36,59 +39,71 @@ namespace Overpaint { return overpaint.layers.length === 0; } - export function remap(overpaint: Overpaint, structure: Structure) { - const layers: Overpaint.Layer[] = []; - for (const layer of overpaint.layers) { - let { loci, color, clear } = layer; - loci = StructureElement.Loci.remap(loci, structure); - if (!StructureElement.Loci.isEmpty(loci)) { - layers.push({ loci, color, clear }); + export function remap(overpaint: Overpaint, structure: Structure): Overpaint { + if (overpaint.kind === 'element-loci') { + const layers: Overpaint.Layer[] = []; + for (const layer of overpaint.layers) { + let { loci, color, clear } = layer; + loci = StructureElement.Loci.remap(loci as StructureElement.Loci, structure); + if (!StructureElement.Loci.isEmpty(loci)) { + layers.push({ loci, color, clear }); + } } + return { kind: 'element-loci', layers }; + } else { + return overpaint; } - return { layers }; } export function merge(overpaint: Overpaint): Overpaint { if (isEmpty(overpaint)) return overpaint; - const { structure } = overpaint.layers[0].loci; - const map = new Map<Color | -1, StructureElement.Loci>(); - let shadowed = StructureElement.Loci.none(structure); - for (let i = 0, il = overpaint.layers.length; i < il; ++i) { - let { loci, color, clear } = overpaint.layers[il - i - 1]; // process from end - loci = StructureElement.Loci.subtract(loci, shadowed); - shadowed = StructureElement.Loci.union(loci, shadowed); - if (!StructureElement.Loci.isEmpty(loci)) { - const colorOrClear = clear ? -1 : color; - if (map.has(colorOrClear)) { - loci = StructureElement.Loci.union(loci, map.get(colorOrClear)!); + if (overpaint.kind === 'element-loci') { + const { structure } = overpaint.layers[0].loci as StructureElement.Loci; + const map = new Map<Color | -1, StructureElement.Loci>(); + let shadowed = StructureElement.Loci.none(structure); + for (let i = 0, il = overpaint.layers.length; i < il; ++i) { + let { loci, color, clear } = overpaint.layers[il - i - 1]; // process from end + loci = StructureElement.Loci.subtract(loci as StructureElement.Loci, shadowed); + shadowed = StructureElement.Loci.union(loci, shadowed); + if (!StructureElement.Loci.isEmpty(loci)) { + const colorOrClear = clear ? -1 : color; + if (map.has(colorOrClear)) { + loci = StructureElement.Loci.union(loci, map.get(colorOrClear)!); + } + map.set(colorOrClear, loci); } - map.set(colorOrClear, loci); } + const layers: Overpaint.Layer[] = []; + map.forEach((loci, colorOrClear) => { + const clear = colorOrClear === -1; + const color = clear ? Color(0) : colorOrClear; + layers.push({ loci, color, clear }); + }); + return { kind: 'element-loci', layers }; + } else { + return overpaint; } - const layers: Overpaint.Layer[] = []; - map.forEach((loci, colorOrClear) => { - const clear = colorOrClear === -1; - const color = clear ? Color(0) : colorOrClear; - layers.push({ loci, color, clear }); - }); - return { layers }; } export function filter(overpaint: Overpaint, filter: Structure): Overpaint { if (isEmpty(overpaint)) return overpaint; - const { structure } = overpaint.layers[0].loci; - const layers: Overpaint.Layer[] = []; - for (const layer of overpaint.layers) { - let { loci, color, clear } = layer; - // filter by first map to the `filter` structure and - // then map back to the original structure of the overpaint loci - const filtered = StructureElement.Loci.remap(loci, filter); - loci = StructureElement.Loci.remap(filtered, structure); - if (!StructureElement.Loci.isEmpty(loci)) { - layers.push({ loci, color, clear }); + if (overpaint.kind === 'element-loci') { + const { structure } = overpaint.layers[0].loci as StructureElement.Loci; + const layers: Overpaint.Layer[] = []; + for (const layer of overpaint.layers) { + let { loci, color, clear } = layer; + // filter by first map to the `filter` structure and + // then map back to the original structure of the overpaint loci + const filtered = StructureElement.Loci.remap(loci as StructureElement.Loci, filter); + loci = StructureElement.Loci.remap(filtered, structure); + if (!StructureElement.Loci.isEmpty(loci)) { + layers.push({ loci, color, clear }); + } } + return { kind: 'element-loci', layers }; + } else { + return overpaint; } - return { layers }; } export type ScriptLayer = { script: Script, color: Color, clear: boolean } @@ -101,7 +116,7 @@ namespace Overpaint { layers.push({ loci, color, clear }); } } - return { layers }; + return { kind: 'element-loci', layers }; } export type BundleLayer = { bundle: StructureElement.Bundle, color: Color, clear: boolean } @@ -112,16 +127,16 @@ namespace Overpaint { const loci = StructureElement.Bundle.toLoci(bundle, structure.root); layers.push({ loci, color, clear }); } - return { layers }; + return { kind: 'element-loci', layers }; } - export function toBundle(overpaint: Overpaint) { + export function toBundle(overpaint: Overpaint<StructureElement.Loci>) { const layers: BundleLayer[] = []; for (let i = 0, il = overpaint.layers.length; i < il; ++i) { const { loci, color, clear } = overpaint.layers[i]; const bundle = StructureElement.Bundle.fromLoci(loci); layers.push({ bundle, color, clear }); } - return { layers }; + return { kind: 'element-loci', layers }; } } \ No newline at end of file diff --git a/src/mol-theme/substance.ts b/src/mol-theme/substance.ts index 104eb9097..d808e5c27 100644 --- a/src/mol-theme/substance.ts +++ b/src/mol-theme/substance.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2021-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> */ @@ -12,15 +12,18 @@ import { shallowEqual } from '../mol-util/object'; export { Substance }; -type Substance = { readonly layers: ReadonlyArray<Substance.Layer> } +type Substance<T extends Loci = Loci> = { + readonly kind: T['kind'] + readonly layers: ReadonlyArray<Substance.Layer<T>> +} -function Substance(layers: ReadonlyArray<Substance.Layer>): Substance { - return { layers }; +function Substance<T extends Loci>(kind: T['kind'], layers: ReadonlyArray<Substance.Layer<T>>): Substance { + return { kind, layers }; } namespace Substance { - export type Layer = { readonly loci: StructureElement.Loci, readonly material: Material, readonly clear: boolean } - export const Empty: Substance = { layers: [] }; + export type Layer<T extends Loci = Loci> = { readonly loci: T, readonly material: Material, readonly clear: boolean } + export const Empty: Substance = { kind: 'empty-loci', layers: [] }; export function areEqual(sA: Substance, sB: Substance) { if (sA.layers.length === 0 && sB.layers.length === 0) return true; @@ -37,66 +40,78 @@ namespace Substance { return overpaint.layers.length === 0; } - export function remap(substance: Substance, structure: Structure) { - const layers: Substance.Layer[] = []; - for (const layer of substance.layers) { - let { loci, material, clear } = layer; - loci = StructureElement.Loci.remap(loci, structure); - if (!StructureElement.Loci.isEmpty(loci)) { - layers.push({ loci, material, clear }); + export function remap(substance: Substance, structure: Structure): Substance { + if (substance.kind === 'element-loci') { + const layers: Substance.Layer[] = []; + for (const layer of substance.layers) { + let { loci, material, clear } = layer; + loci = StructureElement.Loci.remap(loci as StructureElement.Loci, structure); + if (!StructureElement.Loci.isEmpty(loci)) { + layers.push({ loci, material, clear }); + } } + return { kind: 'element-loci', layers }; + } else { + return substance; } - return { layers }; } export function merge(substance: Substance): Substance { if (isEmpty(substance)) return substance; - const { structure } = substance.layers[0].loci; - let clearLoci: StructureElement.Loci | undefined = void 0; - const map = new Map<Material, StructureElement.Loci>(); - let shadowed = StructureElement.Loci.none(structure); - for (let i = 0, il = substance.layers.length; i < il; ++i) { - let { loci, material, clear } = substance.layers[il - i - 1]; // process from end - loci = StructureElement.Loci.subtract(loci, shadowed); - shadowed = StructureElement.Loci.union(loci, shadowed); - if (!StructureElement.Loci.isEmpty(loci)) { - if (clear) { - clearLoci = clearLoci - ? StructureElement.Loci.union(loci, clearLoci) - : loci; - } else { - if (map.has(material)) { - loci = StructureElement.Loci.union(loci, map.get(material)!); + if (substance.kind === 'element-loci') { + const { structure } = substance.layers[0].loci as StructureElement.Loci; + let clearLoci: StructureElement.Loci | undefined = void 0; + const map = new Map<Material, StructureElement.Loci>(); + let shadowed = StructureElement.Loci.none(structure); + for (let i = 0, il = substance.layers.length; i < il; ++i) { + let { loci, material, clear } = substance.layers[il - i - 1]; // process from end + loci = StructureElement.Loci.subtract(loci as StructureElement.Loci, shadowed); + shadowed = StructureElement.Loci.union(loci, shadowed); + if (!StructureElement.Loci.isEmpty(loci)) { + if (clear) { + clearLoci = clearLoci + ? StructureElement.Loci.union(loci, clearLoci) + : loci; + } else { + if (map.has(material)) { + loci = StructureElement.Loci.union(loci, map.get(material)!); + } + map.set(material, loci); } - map.set(material, loci); } } + const layers: Substance.Layer[] = []; + if (clearLoci) { + layers.push({ loci: clearLoci, material: Material(), clear: true }); + } + map.forEach((loci, material) => { + layers.push({ loci, material, clear: false }); + }); + return { kind: 'element-loci', layers }; + } else { + return substance; } - const layers: Substance.Layer[] = []; - if (clearLoci) { - layers.push({ loci: clearLoci, material: Material(), clear: true }); - } - map.forEach((loci, material) => { - layers.push({ loci, material, clear: false }); - }); - return { layers }; } export function filter(substance: Substance, filter: Structure): Substance { if (isEmpty(substance)) return substance; - const { structure } = substance.layers[0].loci; - const layers: Substance.Layer[] = []; - for (const layer of substance.layers) { - let { loci, material, clear } = layer; - // filter by first map to the `filter` structure and - // then map back to the original structure of the substance loci - const filtered = StructureElement.Loci.remap(loci, filter); - loci = StructureElement.Loci.remap(filtered, structure); - if (!StructureElement.Loci.isEmpty(loci)) { - layers.push({ loci, material, clear }); + if (substance.kind === 'element-loci') { + const { structure } = substance.layers[0].loci as StructureElement.Loci; + const layers: Substance.Layer[] = []; + for (const layer of substance.layers) { + let { loci, material, clear } = layer; + // filter by first map to the `filter` structure and + // then map back to the original structure of the substance loci + const filtered = StructureElement.Loci.remap(loci as StructureElement.Loci, filter); + loci = StructureElement.Loci.remap(filtered, structure); + if (!StructureElement.Loci.isEmpty(loci)) { + layers.push({ loci, material, clear }); + } } + return { kind: 'element-loci', layers }; + } else { + return substance; } - return { layers }; } export type ScriptLayer = { script: Script, material: Material, clear: boolean } @@ -109,7 +124,7 @@ namespace Substance { layers.push({ loci, material, clear }); } } - return { layers }; + return { kind: 'element-loci', layers }; } export type BundleLayer = { bundle: StructureElement.Bundle, material: Material, clear: boolean } @@ -120,16 +135,16 @@ namespace Substance { const loci = StructureElement.Bundle.toLoci(bundle, structure.root); layers.push({ loci, material, clear }); } - return { layers }; + return { kind: 'element-loci', layers }; } - export function toBundle(overpaint: Substance) { + export function toBundle(overpaint: Substance<StructureElement.Loci>) { const layers: BundleLayer[] = []; for (let i = 0, il = overpaint.layers.length; i < il; ++i) { const { loci, material, clear } = overpaint.layers[i]; const bundle = StructureElement.Bundle.fromLoci(loci); layers.push({ bundle, material, clear }); } - return { layers }; + return { kind: 'element-loci', layers }; } } \ No newline at end of file -- GitLab