diff --git a/react-components/src/architecture/base/commands/BaseCommand.ts b/react-components/src/architecture/base/commands/BaseCommand.ts index d9b274c1a95..ce6e93cb5e0 100644 --- a/react-components/src/architecture/base/commands/BaseCommand.ts +++ b/react-components/src/architecture/base/commands/BaseCommand.ts @@ -103,6 +103,8 @@ export abstract class BaseCommand { return false; } + protected *getChildren(): Generator {} + /* * Called when the command is invoked * Return true if successful, false otherwise @@ -144,6 +146,9 @@ export abstract class BaseCommand { for (const listener of this._listeners) { listener(this); } + for (const child of this.getChildren()) { + child.update(); + } } // ================================================== diff --git a/react-components/src/architecture/base/commands/BaseFilterCommand.ts b/react-components/src/architecture/base/commands/BaseFilterCommand.ts new file mode 100644 index 00000000000..cc4c48ea61f --- /dev/null +++ b/react-components/src/architecture/base/commands/BaseFilterCommand.ts @@ -0,0 +1,126 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { RenderTargetCommand } from './RenderTargetCommand'; +import { type TranslateDelegate } from '../utilities/TranslateKey'; +import { type Color } from 'three'; +import { type BaseCommand } from './BaseCommand'; + +export abstract class BaseFilterCommand extends RenderTargetCommand { + // ================================================== + // INSTANCE FIELDS/PROPERTIES + // ================================================== + + protected _children: BaseFilterItemCommand[] | undefined = undefined; + + public get children(): BaseFilterItemCommand[] | undefined { + return this._children; + } + + public get hasChildren(): boolean { + return this._children !== undefined && this._children.length > 0; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get icon(): string { + return 'Filter'; + } + + protected override *getChildren(): Generator { + if (this._children === undefined) { + return; + } + for (const child of this._children) { + yield child; + } + } + + // ================================================== + // VIRTUAL METHODS (To be overridden) + // ================================================== + + public initializeChildrenIfNeeded(): void { + if (this._children !== undefined) { + return; + } + this._children = this.createChildren(); + this.attachChildren(); + } + + protected abstract createChildren(): BaseFilterItemCommand[]; + + /** + * Checks if all the children of the current instance are checked. + * Override this method to optimize the logic. + * @returns A boolean value indicating whether all the children are checked. + */ + public get isAllChecked(): boolean { + if (this._children === undefined || this._children.length === 0) { + return false; + } + for (const child of this._children) { + if (!child.isChecked) { + return false; + } + } + return true; + } + + /** + * Toggles the checked state of all child filter items. + * Override this method to optimize the logic. + * If there are no child items, this method does nothing. + */ + public toggleAllChecked(): void { + if (this._children === undefined || this._children.length === 0) { + return; + } + const isAllChecked = this.isAllChecked; + for (const child of this._children) { + child.setChecked(!isAllChecked); + } + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public getSelectedLabel(translate: TranslateDelegate): string { + if (this._children === undefined) { + return this.getNoneLabel(translate); + } + const selected = this._children.filter((child) => child.isChecked); + const counter = selected.length; + if (counter === 0) { + return this.getNoneLabel(translate); + } + if (counter === this._children.length) { + return this.getAllLabel(translate); + } + if (counter === 1) { + return selected[0].getLabel(translate); + } + return counter.toString() + ' ' + this.getSelected(translate); + } + + public getAllLabel(translate: TranslateDelegate): string { + return translate('ALL', 'All'); + } + + public getNoneLabel(translate: TranslateDelegate): string { + return translate('NONE', 'None'); + } + + public getSelected(translate: TranslateDelegate): string { + return translate('SELECTED', 'Selected'); + } +} + +export abstract class BaseFilterItemCommand extends RenderTargetCommand { + public abstract get color(): Color | undefined; + public abstract setChecked(value: boolean): void; +} diff --git a/react-components/src/architecture/base/commands/BaseOptionCommand.ts b/react-components/src/architecture/base/commands/BaseOptionCommand.ts index 9ee5ab825ad..f5877abc515 100644 --- a/react-components/src/architecture/base/commands/BaseOptionCommand.ts +++ b/react-components/src/architecture/base/commands/BaseOptionCommand.ts @@ -2,7 +2,6 @@ * Copyright 2024 Cognite AS */ -import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; import { type BaseCommand } from './BaseCommand'; import { RenderTargetCommand } from './RenderTargetCommand'; @@ -12,44 +11,61 @@ import { RenderTargetCommand } from './RenderTargetCommand'; */ export abstract class BaseOptionCommand extends RenderTargetCommand { - private _options: BaseCommand[] | undefined = undefined; + // ================================================== + // INSTANCE FIELDS/PROPERTIES + // ================================================== + + private _children: BaseCommand[] | undefined = undefined; + + public get children(): BaseCommand[] | undefined { + if (this._children === undefined) { + this._children = this.createChildren(); + } + return this._children; + } + + public get hasChildren(): boolean { + return this._children !== undefined && this._children.length > 0; + } // ================================================== // OVERRIDES // ================================================== - public override attach(renderTarget: RevealRenderTarget): void { - this._renderTarget = renderTarget; - for (const option of this.options) { - if (option instanceof RenderTargetCommand) { - option.attach(renderTarget); - } + protected override *getChildren(): Generator { + if (this._children === undefined) { + return; + } + for (const child of this._children) { + yield child; } } + // ================================================== // VIRTUAL METHODS // ================================================== - protected createOptions(): BaseCommand[] { - return []; // Override this to add options or use the add method + protected createChildren(): BaseCommand[] { + return []; // Override this to add options or use the constructor and add them in } // ================================================== // INSTANCE METHODS // ================================================== - public get options(): BaseCommand[] { - if (this._options === undefined) { - this._options = this.createOptions(); + public get selectedChild(): BaseCommand | undefined { + const children = this.children; + if (children === undefined) { + return undefined; } - return this._options; + return children.find((child) => child.isChecked); } - public get selectedOption(): BaseCommand | undefined { - return this.options.find((option) => option.isChecked); - } - - protected add(command: BaseCommand): void { - this.options.push(command); + protected add(child: BaseCommand): void { + const children = this.children; + if (children === undefined) { + return undefined; + } + children.push(child); } } diff --git a/react-components/src/architecture/base/commands/RenderTargetCommand.ts b/react-components/src/architecture/base/commands/RenderTargetCommand.ts index c9df95aa879..ec2c304b6ad 100644 --- a/react-components/src/architecture/base/commands/RenderTargetCommand.ts +++ b/react-components/src/architecture/base/commands/RenderTargetCommand.ts @@ -67,12 +67,21 @@ export abstract class RenderTargetCommand extends BaseCommand { public attach(renderTarget: RevealRenderTarget): void { this._renderTarget = renderTarget; + this.attachChildren(); } // ================================================== // INSTANCE METHODS // ================================================== + protected attachChildren(): void { + for (const child of this.getChildren()) { + if (child instanceof RenderTargetCommand) { + child.attach(this.renderTarget); + } + } + } + public addTransaction(transaction: Transaction | undefined): void { if (transaction === undefined) { return; diff --git a/react-components/src/architecture/base/concreteCommands/SettingsCommand.ts b/react-components/src/architecture/base/commands/SettingsCommand.ts similarity index 56% rename from react-components/src/architecture/base/concreteCommands/SettingsCommand.ts rename to react-components/src/architecture/base/commands/SettingsCommand.ts index e9b250f1f56..594b8b38f72 100644 --- a/react-components/src/architecture/base/concreteCommands/SettingsCommand.ts +++ b/react-components/src/architecture/base/commands/SettingsCommand.ts @@ -2,17 +2,24 @@ * Copyright 2024 Cognite AS */ -import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; import { type TranslateKey } from '../utilities/TranslateKey'; -import { type BaseCommand } from '../commands/BaseCommand'; -import { RenderTargetCommand } from '../commands/RenderTargetCommand'; +import { type BaseCommand } from './BaseCommand'; +import { RenderTargetCommand } from './RenderTargetCommand'; export class SettingsCommand extends RenderTargetCommand { // ================================================== - // INSTANCE FIELDS + // INSTANCE FIELDS/PROPERTIES // ================================================== - private readonly _commands: BaseCommand[] = []; + private readonly _children: BaseCommand[] = []; + + public get children(): BaseCommand[] { + return this._children; + } + + public get hasChildren(): boolean { + return this._children.length > 0; + } // ================================================== // OVERRIDES @@ -26,27 +33,24 @@ export class SettingsCommand extends RenderTargetCommand { return 'Settings'; } - public override attach(renderTarget: RevealRenderTarget): void { - super.attach(renderTarget); - for (const command of this._commands) { - if (command instanceof RenderTargetCommand) { - command.attach(renderTarget); - } + protected override *getChildren(): Generator { + if (this._children === undefined) { + return; + } + for (const child of this._children) { + yield child; } } + // ================================================== // INSTANCE METHODS // ================================================== public add(command: BaseCommand): void { - if (this._commands.find((c) => c.equals(command)) !== undefined) { + if (this._children.find((c) => c.equals(command)) !== undefined) { console.error('Duplicated command given: ' + command.name); return; } - this._commands.push(command); - } - - public get commands(): BaseCommand[] { - return this._commands; + this._children.push(command); } } diff --git a/react-components/src/architecture/base/commands/mocks/MockActionCommand.ts b/react-components/src/architecture/base/commands/mocks/MockActionCommand.ts new file mode 100644 index 00000000000..9d1c2ed0a5c --- /dev/null +++ b/react-components/src/architecture/base/commands/mocks/MockActionCommand.ts @@ -0,0 +1,32 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type TranslateKey } from '../../utilities/TranslateKey'; +import { RenderTargetCommand } from '../RenderTargetCommand'; + +export class MockActionCommand extends RenderTargetCommand { + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: 'Action' }; + } + + public override get icon(): string { + return 'Sun'; + } + + public override get shortCutKey(): string | undefined { + return 'A'; + } + + public override get shortCutKeyOnCtrl(): boolean { + return true; + } + + protected override invokeCore(): boolean { + return true; + } +} diff --git a/react-components/src/architecture/base/commands/mocks/MockCheckableCommand.ts b/react-components/src/architecture/base/commands/mocks/MockCheckableCommand.ts new file mode 100644 index 00000000000..debd4bf9e1b --- /dev/null +++ b/react-components/src/architecture/base/commands/mocks/MockCheckableCommand.ts @@ -0,0 +1,30 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type TranslateKey } from '../../utilities/TranslateKey'; +import { RenderTargetCommand } from '../RenderTargetCommand'; + +export class MockCheckableCommand extends RenderTargetCommand { + public value = false; + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: 'Checkable action' }; + } + + public override get icon(): string { + return 'Snow'; + } + + public override get isChecked(): boolean { + return this.value; + } + + protected override invokeCore(): boolean { + this.value = !this.value; + return true; + } +} diff --git a/react-components/src/architecture/base/commands/mocks/MockEnumOptionCommand.ts b/react-components/src/architecture/base/commands/mocks/MockEnumOptionCommand.ts new file mode 100644 index 00000000000..b8b2a06be21 --- /dev/null +++ b/react-components/src/architecture/base/commands/mocks/MockEnumOptionCommand.ts @@ -0,0 +1,62 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { BaseOptionCommand } from '../BaseOptionCommand'; +import { RenderTargetCommand } from '../RenderTargetCommand'; +import { type TranslateKey } from '../../utilities/TranslateKey'; + +enum MockEnum { + Red = 'Red', + Green = 'Green', + Blue = 'Blue' +} + +export class MockEnumOptionCommand extends BaseOptionCommand { + public value = MockEnum.Red; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + constructor() { + super(); + for (const value of [MockEnum.Red, MockEnum.Green, MockEnum.Blue]) { + this.add(new OptionItemCommand(this, value)); + } + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: 'Enum option' }; + } +} + +// Note: This is not exported, as it is only used internally + +class OptionItemCommand extends RenderTargetCommand { + private readonly _value: MockEnum; + private readonly _option: MockEnumOptionCommand; + + public constructor(option: MockEnumOptionCommand, value: MockEnum) { + super(); + this._option = option; + this._value = value; + } + + public override get tooltip(): TranslateKey { + return { fallback: this._value.toString() }; + } + + public override get isChecked(): boolean { + return this._option.value === this._value; + } + + public override invokeCore(): boolean { + this._option.value = this._value; + return true; + } +} diff --git a/react-components/src/architecture/base/commands/mocks/MockFilterCommand.ts b/react-components/src/architecture/base/commands/mocks/MockFilterCommand.ts new file mode 100644 index 00000000000..44f0aa1f27a --- /dev/null +++ b/react-components/src/architecture/base/commands/mocks/MockFilterCommand.ts @@ -0,0 +1,108 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Color } from 'three'; +import { BaseFilterCommand, BaseFilterItemCommand } from '../BaseFilterCommand'; +import { type TranslateKey } from '../../utilities/TranslateKey'; +import { CommandsUpdater } from '../../reactUpdaters/CommandsUpdater'; + +export class MockFilterCommand extends BaseFilterCommand { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _timeStamp: number | undefined = undefined; + private _useAllColor: boolean = true; + private readonly _testDynamic: boolean = false; + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { key: '', fallback: 'Filter' }; + } + + protected override createChildren(): FilterItemCommand[] { + if (this._useAllColor) { + return [ + new FilterItemCommand('Red', new Color(Color.NAMES.red)), + new FilterItemCommand('Green', new Color(Color.NAMES.green)), + new FilterItemCommand('Blue', new Color(Color.NAMES.blue)), + new FilterItemCommand('Yellow', new Color(Color.NAMES.yellow)), + new FilterItemCommand('Cyan', new Color(Color.NAMES.cyan)), + new FilterItemCommand('Magenta', new Color(Color.NAMES.magenta)), + new FilterItemCommand('No color') + ]; + } else { + return [ + new FilterItemCommand('Black', new Color(Color.NAMES.black)), + new FilterItemCommand('White', new Color(Color.NAMES.white)) + ]; + } + } + + public override initializeChildrenIfNeeded(): void { + if (!this._testDynamic) { + super.initializeChildrenIfNeeded(); + return; + } + // This updates the options every 5 seconds. Used for testing purposes. + const timeStamp = Date.now(); + if (this._timeStamp !== undefined && Math.abs(this._timeStamp - timeStamp) < 5000) { + return; + } + this._timeStamp = timeStamp; + this._useAllColor = !this._useAllColor; + this._children = undefined; + super.initializeChildrenIfNeeded(); + } +} + +// Note: This is not exported, as it is only used internally + +class FilterItemCommand extends BaseFilterItemCommand { + private readonly _name: string; + private readonly _color?: Color; + private _use = true; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(name: string, color?: Color) { + super(); + this._name = name; + this._color = color; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: this._name }; + } + + public override get isChecked(): boolean { + return this._use; + } + + public override invokeCore(): boolean { + this._use = !this._use; + return true; + } + + public override get color(): Color | undefined { + return this._color; + } + + public setChecked(value: boolean): void { + if (this._use === value) { + return; + } + this._use = value; + CommandsUpdater.update(this._renderTarget); + } +} diff --git a/react-components/src/architecture/base/commands/mocks/MockNumberOptionCommand.ts b/react-components/src/architecture/base/commands/mocks/MockNumberOptionCommand.ts new file mode 100644 index 00000000000..279001bfe39 --- /dev/null +++ b/react-components/src/architecture/base/commands/mocks/MockNumberOptionCommand.ts @@ -0,0 +1,56 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { BaseOptionCommand } from '../BaseOptionCommand'; +import { RenderTargetCommand } from '../RenderTargetCommand'; +import { type TranslateKey } from '../../utilities/TranslateKey'; + +export class MockNumberOptionCommand extends BaseOptionCommand { + public value = 1; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + constructor() { + super(); + for (let value = 1; value <= 10; value++) { + this.add(new OptionItemCommand(this, value)); + } + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: 'Number option' }; + } +} + +// Note: This is not exported, as it is only used internally + +class OptionItemCommand extends RenderTargetCommand { + private readonly _value: number; + private readonly _option: MockNumberOptionCommand; + + public constructor(option: MockNumberOptionCommand, value: number) { + super(); + this._option = option; + this._value = value; + } + + public override get tooltip(): TranslateKey { + return { fallback: this._value.toString() }; + } + + public override get isChecked(): boolean { + return this._option.value === this._value; + } + + public override invokeCore(): boolean { + this._option.value = this._value; + return true; + } +} diff --git a/react-components/src/architecture/base/commands/mocks/MockSettingsCommand.ts b/react-components/src/architecture/base/commands/mocks/MockSettingsCommand.ts new file mode 100644 index 00000000000..aed7c42fdaa --- /dev/null +++ b/react-components/src/architecture/base/commands/mocks/MockSettingsCommand.ts @@ -0,0 +1,42 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { MockActionCommand } from './MockActionCommand'; +import { MockToggleCommand } from './MockToggleCommand'; +import { MockCheckableCommand } from './MockCheckableCommand'; +import { MockEnumOptionCommand } from './MockEnumOptionCommand'; +import { MockSliderCommand } from './MockSliderCommand'; +import { SettingsCommand } from '../SettingsCommand'; +import { MockFilterCommand } from './MockFilterCommand'; +import { type TranslateKey } from '../../utilities/TranslateKey'; +import { MockNumberOptionCommand } from './MockNumberOptionCommand'; + +export class MockSettingsCommand extends SettingsCommand { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + constructor() { + super(); + this.add(new MockToggleCommand()); + this.add(new MockSliderCommand()); + this.add(new MockEnumOptionCommand()); + this.add(new MockNumberOptionCommand()); + this.add(new MockActionCommand()); + this.add(new MockCheckableCommand()); + this.add(new MockFilterCommand()); + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: 'Mock Settings' }; + } + + public override get icon(): string | undefined { + return 'Bug'; + } +} diff --git a/react-components/src/architecture/base/commands/mocks/MockSliderCommand.ts b/react-components/src/architecture/base/commands/mocks/MockSliderCommand.ts new file mode 100644 index 00000000000..c1876a469ee --- /dev/null +++ b/react-components/src/architecture/base/commands/mocks/MockSliderCommand.ts @@ -0,0 +1,34 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type TranslateKey } from '../../utilities/TranslateKey'; +import { BaseSliderCommand } from '../BaseSliderCommand'; + +export class MockSliderCommand extends BaseSliderCommand { + public _value = 5; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor() { + super(1, 10, 1); + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: 'Slider' }; + } + + public override get value(): number { + return this._value; + } + + public override set value(value: number) { + this._value = value; + } +} diff --git a/react-components/src/architecture/base/commands/mocks/MockToggleCommand.ts b/react-components/src/architecture/base/commands/mocks/MockToggleCommand.ts new file mode 100644 index 00000000000..98e8f1a988e --- /dev/null +++ b/react-components/src/architecture/base/commands/mocks/MockToggleCommand.ts @@ -0,0 +1,30 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type TranslateKey } from '../../utilities/TranslateKey'; +import { RenderTargetCommand } from '../RenderTargetCommand'; + +export class MockToggleCommand extends RenderTargetCommand { + public value = false; + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: 'Boolean' }; + } + + public override get isToggle(): boolean { + return true; + } + + public override get isChecked(): boolean { + return this.value; + } + + protected override invokeCore(): boolean { + this.value = !this.value; + return true; + } +} diff --git a/react-components/src/architecture/base/concreteCommands/KeyboardSpeedCommand.ts b/react-components/src/architecture/base/concreteCommands/KeyboardSpeedCommand.ts index acc2b7b9ba0..ea5e3f48e17 100644 --- a/react-components/src/architecture/base/concreteCommands/KeyboardSpeedCommand.ts +++ b/react-components/src/architecture/base/concreteCommands/KeyboardSpeedCommand.ts @@ -16,7 +16,7 @@ export class KeyboardSpeedCommand extends BaseOptionCommand { constructor(supportedTypes = DEFAULT_OPTIONS) { super(); for (const value of supportedTypes) { - this.add(new OptionCommand(value)); + this.add(new OptionItemCommand(value)); } } @@ -30,7 +30,7 @@ export class KeyboardSpeedCommand extends BaseOptionCommand { } // Note: This is not exported, as it is only used internally -class OptionCommand extends RenderTargetCommand { +class OptionItemCommand extends RenderTargetCommand { private readonly _value: number; public constructor(value: number) { diff --git a/react-components/src/architecture/base/concreteCommands/PointCloudFilterCommand.ts b/react-components/src/architecture/base/concreteCommands/PointCloudFilterCommand.ts new file mode 100644 index 00000000000..81b09377873 --- /dev/null +++ b/react-components/src/architecture/base/concreteCommands/PointCloudFilterCommand.ts @@ -0,0 +1,187 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type CognitePointCloudModel, type WellKnownAsprsPointClassCodes } from '@cognite/reveal'; +import { type TranslateKey } from '../utilities/TranslateKey'; +import { type Color } from 'three'; +import { BaseFilterCommand, BaseFilterItemCommand } from '../commands/BaseFilterCommand'; +import { CommandsUpdater } from '../reactUpdaters/CommandsUpdater'; +import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; + +export class PointCloudFilterCommand extends BaseFilterCommand { + private _revisionId: number | undefined = undefined; + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { key: 'POINT_FILTER', fallback: 'Points filter' }; + } + + public override get isEnabled(): boolean { + const pointCloud = getFirstPointCloudWithClasses(this.renderTarget); + if (pointCloud === undefined) { + return false; + } + return true; + } + + public override initializeChildrenIfNeeded(): void { + const pointCloud = getFirstPointCloudWithClasses(this.renderTarget); + if (pointCloud === undefined) { + this._children = undefined; + this._revisionId = undefined; + return; + } + if (this._revisionId === pointCloud.revisionId) { + return; + } + this._revisionId = pointCloud.revisionId; + this._children = undefined; + super.initializeChildrenIfNeeded(); + } + + protected createChildren(): FilterItemCommand[] { + const pointCloud = getFirstPointCloudWithClasses(this.renderTarget); + if (pointCloud === undefined) { + return []; + } + const classes = pointCloud.getClasses(); + if (classes === undefined || classes.length === 0) { + return []; + } + const children = []; + for (const c of classes) { + const pointClass = new PointClass(c.name, c.code, c.color); + children.push(new FilterItemCommand(pointClass)); + } + return children; + } + + public override get isAllChecked(): boolean { + const pointCloud = getFirstPointCloudWithClasses(this.renderTarget); + if (pointCloud === undefined) { + return false; + } + return isClassesVisible(pointCloud); + } + + public override toggleAllChecked(): void { + const pointCloud = getFirstPointCloudWithClasses(this.renderTarget); + if (pointCloud === undefined) { + return; + } + const isAllChecked = isClassesVisible(pointCloud); + const classes = pointCloud.getClasses(); + if (classes === undefined || classes.length === 0) { + return; + } + for (const c of classes) { + pointCloud.setClassVisible(c.code, !isAllChecked); + } + } +} + +// Note: This is not exported, as it is only used internally + +class FilterItemCommand extends BaseFilterItemCommand { + private readonly _pointClass: PointClass; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(pointClass: PointClass) { + super(); + this._pointClass = pointClass; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { fallback: this._pointClass.displayName }; + } + + public override get isChecked(): boolean { + const pointCloud = getFirstPointCloudWithClasses(this.renderTarget); + if (pointCloud === undefined) { + return false; + } + return pointCloud.isClassVisible(this._pointClass.code); + } + + public override invokeCore(): boolean { + const pointCloud = getFirstPointCloudWithClasses(this.renderTarget); + if (pointCloud === undefined) { + return false; + } + const isVisible = pointCloud.isClassVisible(this._pointClass.code); + pointCloud.setClassVisible(this._pointClass.code, !isVisible); + return true; + } + + public override get color(): Color | undefined { + return this._pointClass.color; + } + + public setChecked(value: boolean): void { + const pointCloud = getFirstPointCloudWithClasses(this.renderTarget); + if (pointCloud === undefined) { + return; + } + pointCloud.setClassVisible(this._pointClass.code, value); + CommandsUpdater.update(this._renderTarget); + } +} + +class PointClass { + name: string; + code: number | WellKnownAsprsPointClassCodes; + color: Color; + + constructor(name: string, code: number | WellKnownAsprsPointClassCodes, color: Color) { + this.name = name; + this.code = code; + this.color = color; + } + + public get displayName(): string { + const name = this.name; + const changedName = name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, ' '); + + if (!name.startsWith('ReservedOr')) { + return changedName; + } + const betterKey = changedName.slice('ReservedOr'.length); + return `${betterKey} (legacy)`; + } +} + +function getFirstPointCloudWithClasses( + renderTarget: RevealRenderTarget +): CognitePointCloudModel | undefined { + for (const pointCloud of renderTarget.getPointClouds()) { + const classes = pointCloud.getClasses(); + if (classes === undefined || classes.length === 0) { + continue; + } + } + return undefined; +} + +function isClassesVisible(pointCloud: CognitePointCloudModel): boolean { + const classes = pointCloud.getClasses(); + if (classes === undefined || classes.length === 0) { + return false; + } + for (const c of classes) { + if (!pointCloud.isClassVisible(c.code)) { + return false; + } + } + return true; +} diff --git a/react-components/src/architecture/base/concreteCommands/SetPointColorTypeCommand.ts b/react-components/src/architecture/base/concreteCommands/SetPointColorTypeCommand.ts index 77855e41882..4de861c55c3 100644 --- a/react-components/src/architecture/base/concreteCommands/SetPointColorTypeCommand.ts +++ b/react-components/src/architecture/base/concreteCommands/SetPointColorTypeCommand.ts @@ -23,7 +23,7 @@ export class SetPointColorTypeCommand extends BaseOptionCommand { constructor(supportedTypes = DEFAULT_OPTIONS) { super(); for (const value of supportedTypes) { - this.add(new OptionCommand(value)); + this.add(new OptionItemCommand(value)); } } @@ -42,7 +42,7 @@ export class SetPointColorTypeCommand extends BaseOptionCommand { // Note: This is not exported, as it is only used internally -class OptionCommand extends RenderTargetCommand { +class OptionItemCommand extends RenderTargetCommand { private readonly _value: PointColorType; public constructor(value: PointColorType) { diff --git a/react-components/src/architecture/base/concreteCommands/SetPointShapeCommand.ts b/react-components/src/architecture/base/concreteCommands/SetPointShapeCommand.ts index e2fdb16cf9b..3e89da167fb 100644 --- a/react-components/src/architecture/base/concreteCommands/SetPointShapeCommand.ts +++ b/react-components/src/architecture/base/concreteCommands/SetPointShapeCommand.ts @@ -17,7 +17,7 @@ export class SetPointShapeCommand extends BaseOptionCommand { constructor(supportedTypes = DEFAULT_OPTIONS) { super(); for (const value of supportedTypes) { - this.add(new OptionCommand(value)); + this.add(new OptionItemCommand(value)); } } @@ -36,7 +36,7 @@ export class SetPointShapeCommand extends BaseOptionCommand { // Note: This is not exported, as it is only used internally -class OptionCommand extends RenderTargetCommand { +class OptionItemCommand extends RenderTargetCommand { private readonly _value: PointShape; public constructor(value: PointShape) { diff --git a/react-components/src/architecture/concrete/config/StoryBookConfig.ts b/react-components/src/architecture/concrete/config/StoryBookConfig.ts index 6fc8d4d1848..5889ecd90fb 100644 --- a/react-components/src/architecture/concrete/config/StoryBookConfig.ts +++ b/react-components/src/architecture/concrete/config/StoryBookConfig.ts @@ -21,11 +21,14 @@ import { MeasurementTool } from '../measurements/MeasurementTool'; import { ClipTool } from '../clipping/ClipTool'; import { KeyboardSpeedCommand } from '../../base/concreteCommands/KeyboardSpeedCommand'; import { ObservationsTool } from '../observations/ObservationsTool'; -import { SettingsCommand } from '../../base/concreteCommands/SettingsCommand'; +import { SettingsCommand } from '../../base/commands/SettingsCommand'; import { SetQualityCommand } from '../../base/concreteCommands/SetQualityCommand'; import { SetPointSizeCommand } from '../../base/concreteCommands/SetPointSizeCommand'; import { SetPointColorTypeCommand } from '../../base/concreteCommands/SetPointColorTypeCommand'; import { SetPointShapeCommand } from '../../base/concreteCommands/SetPointShapeCommand'; +import { MockSettingsCommand } from '../../base/commands/mocks/MockSettingsCommand'; +import { PointCloudFilterCommand } from '../../base/concreteCommands/PointCloudFilterCommand'; +import { MockFilterCommand } from '../../base/commands/mocks/MockFilterCommand'; export class StoryBookConfig extends BaseRevealConfig { // ================================================== @@ -42,6 +45,7 @@ export class StoryBookConfig extends BaseRevealConfig { settings.add(new SetPointSizeCommand()); settings.add(new SetPointColorTypeCommand()); settings.add(new SetPointShapeCommand()); + settings.add(new PointCloudFilterCommand()); return [ new SetFlexibleControlsTypeCommand(FlexibleControlsType.Orbit), @@ -51,6 +55,9 @@ export class StoryBookConfig extends BaseRevealConfig { new SetAxisVisibleCommand(), new ToggleMetricUnitsCommand(), new KeyboardSpeedCommand(), + settings, + new MockSettingsCommand(), + new MockFilterCommand(), undefined, new ExampleTool(), new MeasurementTool(), @@ -59,8 +66,7 @@ export class StoryBookConfig extends BaseRevealConfig { undefined, new SetTerrainVisibleCommand(), new UpdateTerrainCommand(), - undefined, - settings + undefined ]; } diff --git a/react-components/src/architecture/concrete/observations/SaveObservationsCommand.ts b/react-components/src/architecture/concrete/observations/SaveObservationsCommand.ts index 379495e2531..990eb3bc8e0 100644 --- a/react-components/src/architecture/concrete/observations/SaveObservationsCommand.ts +++ b/react-components/src/architecture/concrete/observations/SaveObservationsCommand.ts @@ -6,6 +6,7 @@ import { type ButtonType } from '../../../components/Architecture/types'; import { type TranslateKey } from '../../base/utilities/TranslateKey'; import { Changes } from '../../base/domainObjectsHelpers/Changes'; import { ObservationsCommand } from './ObservationsCommand'; +import { CommandsUpdater } from '../../base/reactUpdaters/CommandsUpdater'; export class SaveObservationsCommand extends ObservationsCommand { public override get icon(): IconType { @@ -44,7 +45,7 @@ export class SaveObservationsCommand extends ObservationsCommand { const observation = this.getObservationsDomainObject(); observation?.notify(Changes.geometry); - this.renderTarget.commandsController.update(); + CommandsUpdater.update(this.renderTarget); }) .catch((e) => { toast.error({ fallback: 'Unable to publish observation changes: ' + e }.fallback); diff --git a/react-components/src/components/Architecture/CommandButton.tsx b/react-components/src/components/Architecture/CommandButton.tsx index 57ccad1287f..63629ab0df3 100644 --- a/react-components/src/components/Architecture/CommandButton.tsx +++ b/react-components/src/components/Architecture/CommandButton.tsx @@ -12,12 +12,10 @@ import { LabelWithShortcut } from './LabelWithShortcut'; export const CommandButton = ({ inputCommand, - isHorizontal = false, - usedInSettings = false + isHorizontal = false }: { inputCommand: BaseCommand; isHorizontal: boolean; - usedInSettings?: boolean; }): ReactElement => { const renderTarget = useRenderTarget(); const { t } = useTranslation(); @@ -43,7 +41,7 @@ export const CommandButton = ({ return () => { command.removeEventListener(update); }; - }, [command.isEnabled, command.isChecked, command.isVisible]); + }, [command]); if (!isVisible) { return <>; @@ -55,7 +53,7 @@ export const CommandButton = ({ return ( } - disabled={usedInSettings || label === undefined} + disabled={label === undefined} appendTo={document.body} placement={placement}> + }}> ); }; diff --git a/react-components/src/components/Architecture/CommandButtons.tsx b/react-components/src/components/Architecture/CommandButtons.tsx index 32c6909a699..b6a94678c5c 100644 --- a/react-components/src/components/Architecture/CommandButtons.tsx +++ b/react-components/src/components/Architecture/CommandButtons.tsx @@ -9,26 +9,21 @@ import { OptionButton } from './OptionButton'; import { BaseOptionCommand } from '../../architecture/base/commands/BaseOptionCommand'; import { CommandButton } from './CommandButton'; import { SettingsButton } from './SettingsButton'; -import { SettingsCommand } from '../../architecture/base/concreteCommands/SettingsCommand'; +import { SettingsCommand } from '../../architecture/base/commands/SettingsCommand'; +import { BaseFilterCommand } from '../../architecture/base/commands/BaseFilterCommand'; +import { FilterButton } from './FilterButton'; -export function createButton( - command: BaseCommand, - isHorizontal = false, - usedInSettings = false -): ReactElement { +export function createButton(command: BaseCommand, isHorizontal = false): ReactElement { + if (command instanceof BaseFilterCommand) { + return ; + } if (command instanceof SettingsCommand) { return ; - } else if (command instanceof BaseOptionCommand) { + } + if (command instanceof BaseOptionCommand) { return ; - } else { - return ( - - ); } + return ; } export function createButtonFromCommandConstructor( diff --git a/react-components/src/components/Architecture/FilterButton.tsx b/react-components/src/components/Architecture/FilterButton.tsx new file mode 100644 index 00000000000..d879d1e32b8 --- /dev/null +++ b/react-components/src/components/Architecture/FilterButton.tsx @@ -0,0 +1,165 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type MouseEvent +} from 'react'; +import { Button, Dropdown, Menu, Tooltip as CogsTooltip, type IconType } from '@cognite/cogs.js'; +import { useTranslation } from '../i18n/I18n'; +import { type BaseCommand } from '../../architecture/base/commands/BaseCommand'; +import { useRenderTarget } from '../RevealCanvas/ViewerContext'; +import { + getButtonType, + getDefaultCommand, + getFlexDirection, + getTooltipPlacement, + getIcon +} from './utilities'; +import { LabelWithShortcut } from './LabelWithShortcut'; +import { useClickOutside } from './useClickOutside'; +import styled from 'styled-components'; +import { BaseFilterCommand } from '../../architecture/base/commands/BaseFilterCommand'; +import { FilterItem } from './FilterItem'; + +export const FilterButton = ({ + inputCommand, + isHorizontal = false, + usedInSettings = false +}: { + inputCommand: BaseFilterCommand; + isHorizontal: boolean; + usedInSettings?: boolean; +}): ReactElement => { + const renderTarget = useRenderTarget(); + const { t } = useTranslation(); + const command = useMemo( + () => getDefaultCommand(inputCommand, renderTarget), + [] + ); + + const [isEnabled, setEnabled] = useState(true); + const [isVisible, setVisible] = useState(true); + const [uniqueId, setUniqueId] = useState(0); + const [icon, setIcon] = useState(undefined); + const [isOpen, setOpen] = useState(false); + const [isAllChecked, setAllChecked] = useState(false); + const [selectedLabel, setSelectedLabel] = useState(''); + + const update = useCallback( + (command: BaseCommand) => { + setEnabled(command.isEnabled); + setVisible(command.isVisible); + setUniqueId(command.uniqueId); + setIcon(getIcon(command)); + if (command instanceof BaseFilterCommand) { + setAllChecked(command.isAllChecked); + setSelectedLabel(command.getSelectedLabel(t)); + } + }, + [command] + ); + + useEffect(() => { + update(command); + command.addEventListener(update); + return () => { + command.removeEventListener(update); + }; + }, [command]); + + const outsideAction = (): boolean => { + if (!isOpen) { + return false; + } + setOpen(false); + renderTarget.domElement.focus(); + return true; + }; + + const menuRef = useRef(null); + useClickOutside(menuRef, outsideAction); + + if (!isVisible) { + return <>; + } + const placement = getTooltipPlacement(isHorizontal); + const label = usedInSettings ? undefined : command.getLabel(t); + const shortcut = command.getShortCutKeys(); + const flexDirection = getFlexDirection(isHorizontal); + + command.initializeChildrenIfNeeded(); + const children = command.children; + if (children === undefined || !command.hasChildren) { + return <>; + } + return ( + } + disabled={usedInSettings || label === undefined} + appendTo={document.body} + placement={placement}> + + + { + command.toggleAllChecked(); + }}> + {command.getAllLabel(t)} + + + {children.map((child, _index): ReactElement => { + return ; + })} + + + + }> + + + + ); +}; + +const StyledMenuItems = styled.div` + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; +`; diff --git a/react-components/src/components/Architecture/FilterItem.tsx b/react-components/src/components/Architecture/FilterItem.tsx new file mode 100644 index 00000000000..194b2aeea3d --- /dev/null +++ b/react-components/src/components/Architecture/FilterItem.tsx @@ -0,0 +1,68 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { useCallback, useEffect, useState, type ReactElement } from 'react'; +import { Menu } from '@cognite/cogs.js'; +import { useTranslation } from '../i18n/I18n'; +import { type BaseCommand } from '../../architecture/base/commands/BaseCommand'; +import styled from 'styled-components'; +import { type Color } from 'three'; +import { type BaseFilterItemCommand } from '../../architecture/base/commands/BaseFilterCommand'; + +export const FilterItem = ({ command }: { command: BaseFilterItemCommand }): ReactElement => { + const { t } = useTranslation(); + + const [isChecked, setChecked] = useState(false); + const [isEnabled, setEnabled] = useState(true); + const [isVisible, setVisible] = useState(true); + const [uniqueId, setUniqueId] = useState(0); + + const update = useCallback((command: BaseCommand) => { + setChecked(command.isChecked); + setEnabled(command.isEnabled); + setVisible(command.isVisible); + setUniqueId(command.uniqueId); + }, []); + + useEffect(() => { + update(command); + command.addEventListener(update); + return () => { + command.removeEventListener(update); + }; + }, [command]); + + if (!isVisible) { + return <>; + } + return ( + { + command.invoke(); + }}> + + {command.color !== undefined && } + + + + ); +}; + +const ColorBox = styled.div<{ backgroundColor: Color }>` + width: 16px; + height: 16px; + border: 1px solid black; + display: inline-block; + background-color: ${(props) => props.backgroundColor.getStyle()}; +`; + +const CenteredContainer = styled.div` + display: flex; + row-gap: 3px; + gap: 3px; + align-items: center; +`; diff --git a/react-components/src/components/Architecture/OptionButton.tsx b/react-components/src/components/Architecture/OptionButton.tsx index c77f0590d48..4dbe664d4cb 100644 --- a/react-components/src/components/Architecture/OptionButton.tsx +++ b/react-components/src/components/Architecture/OptionButton.tsx @@ -2,12 +2,20 @@ * Copyright 2024 Cognite AS */ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type MouseEvent +} from 'react'; import { Button, Dropdown, Menu, Tooltip as CogsTooltip } from '@cognite/cogs.js'; import { useTranslation } from '../i18n/I18n'; import { type BaseCommand } from '../../architecture/base/commands/BaseCommand'; import { useRenderTarget } from '../RevealCanvas/ViewerContext'; -import { BaseOptionCommand } from '../../architecture/base/commands/BaseOptionCommand'; +import { type BaseOptionCommand } from '../../architecture/base/commands/BaseOptionCommand'; import { getButtonType, getDefaultCommand, @@ -30,23 +38,16 @@ export const OptionButton = ({ }): ReactElement => { const renderTarget = useRenderTarget(); const { t } = useTranslation(); - const command = useMemo(() => getDefaultCommand(inputCommand, renderTarget), []); + const command = useMemo( + () => getDefaultCommand(inputCommand, renderTarget), + [] + ); const [isOpen, setOpen] = useState(false); const [isEnabled, setEnabled] = useState(true); const [isVisible, setVisible] = useState(true); const [uniqueId, setUniqueId] = useState(0); - const postAction = (): void => { - setOpen(false); - renderTarget.domElement.focus(); - }; - - const menuRef = useRef(null); - useClickOutside(menuRef, () => { - postAction(); - }); - const update = useCallback((command: BaseCommand) => { setEnabled(command.isEnabled); setVisible(command.isVisible); @@ -61,61 +62,78 @@ export const OptionButton = ({ }; }, [command]); - if (!(command instanceof BaseOptionCommand)) { - return <>; - } - if (!isVisible) { + const outsideAction = (): boolean => { + if (!isOpen) { + return false; + } + postAction(); + return true; + }; + + const postAction = (): void => { + setOpen(false); + renderTarget.domElement.focus(); + }; + + const menuRef = useRef(null); + useClickOutside(menuRef, outsideAction); + + if (!isVisible || command.children === undefined) { return <>; } const placement = getTooltipPlacement(isHorizontal); const label = usedInSettings ? undefined : command.getLabel(t); const shortcut = command.getShortCutKeys(); const flexDirection = getFlexDirection(isHorizontal); - const options = command.options; - const selectedLabel = command.selectedOption?.getLabel(t); + const children = command.children; + const selectedLabel = command.selectedChild?.getLabel(t); return ( -
- } - disabled={usedInSettings || label === undefined} + } + disabled={usedInSettings || label === undefined} + appendTo={document.body} + placement={placement}> + - - {options.map((command, _index): ReactElement => { - return createMenuItem(command, t, postAction); + {children.map((child, _index): ReactElement => { + return createMenuItem(child, t, postAction); })} - }> - - - -
+ + }> + + + ); }; diff --git a/react-components/src/components/Architecture/RevealButtons.tsx b/react-components/src/components/Architecture/RevealButtons.tsx index 8541f38f9e1..3bd16695867 100644 --- a/react-components/src/components/Architecture/RevealButtons.tsx +++ b/react-components/src/components/Architecture/RevealButtons.tsx @@ -13,11 +13,12 @@ import { MeasurementTool } from '../../architecture/concrete/measurements/Measur import { KeyboardSpeedCommand } from '../../architecture/base/concreteCommands/KeyboardSpeedCommand'; import { ObservationsTool } from '../../architecture/concrete/observations/ObservationsTool'; import { createButtonFromCommandConstructor } from './CommandButtons'; -import { SettingsCommand } from '../../architecture/base/concreteCommands/SettingsCommand'; +import { SettingsCommand } from '../../architecture/base/commands/SettingsCommand'; import { SetPointColorTypeCommand } from '../../architecture/base/concreteCommands/SetPointColorTypeCommand'; import { SetPointShapeCommand } from '../../architecture/base/concreteCommands/SetPointShapeCommand'; import { SetPointSizeCommand } from '../../architecture/base/concreteCommands/SetPointSizeCommand'; import { SetQualityCommand } from '../../architecture/base/concreteCommands/SetQualityCommand'; +import { PointCloudFilterCommand } from '../../architecture/base/concreteCommands/PointCloudFilterCommand'; export class RevealButtons { static Settings = (): ReactElement => createButtonFromCommandConstructor(() => createSettings()); @@ -60,5 +61,6 @@ function createSettings(): SettingsCommand { settings.add(new SetPointSizeCommand()); settings.add(new SetPointColorTypeCommand()); settings.add(new SetPointShapeCommand()); + settings.add(new PointCloudFilterCommand()); return settings; } diff --git a/react-components/src/components/Architecture/SettingsButton.tsx b/react-components/src/components/Architecture/SettingsButton.tsx index 742c8b8efc8..c8efbd8ac5c 100644 --- a/react-components/src/components/Architecture/SettingsButton.tsx +++ b/react-components/src/components/Architecture/SettingsButton.tsx @@ -24,11 +24,12 @@ import { import { LabelWithShortcut } from './LabelWithShortcut'; import { type TranslateDelegate } from '../../architecture/base/utilities/TranslateKey'; import styled from 'styled-components'; -import { SettingsCommand } from '../../architecture/base/concreteCommands/SettingsCommand'; -import { createButton } from './CommandButtons'; +import { type SettingsCommand } from '../../architecture/base/commands/SettingsCommand'; import { BaseOptionCommand } from '../../architecture/base/commands/BaseOptionCommand'; import { OptionButton } from './OptionButton'; import { BaseSliderCommand } from '../../architecture/base/commands/BaseSliderCommand'; +import { BaseFilterCommand } from '../../architecture/base/commands/BaseFilterCommand'; +import { FilterButton } from './FilterButton'; export const SettingsButton = ({ inputCommand, @@ -39,7 +40,10 @@ export const SettingsButton = ({ }): ReactElement => { const renderTarget = useRenderTarget(); const { t } = useTranslation(); - const command = useMemo(() => getDefaultCommand(inputCommand, renderTarget), []); + const command = useMemo( + () => getDefaultCommand(inputCommand, renderTarget), + [] + ); const [isOpen, setOpen] = useState(false); const [isEnabled, setEnabled] = useState(true); @@ -62,17 +66,14 @@ export const SettingsButton = ({ }; }, [command]); - if (!(command instanceof SettingsCommand)) { - return <>; - } - if (!isVisible) { + if (!isVisible || !command.hasChildren) { return <>; } const placement = getTooltipPlacement(isHorizontal); const label = command.getLabel(t); const shortcut = command.getShortCutKeys(); const flexDirection = getFlexDirection(isHorizontal); - const commands = command.commands; + const children = command.children; return ( - {commands.map((command, _index): ReactElement | undefined => { - return createMenuItem(command, t); + {children.map((child, _index): ReactElement | undefined => { + return createMenuItem(child, t); })} - + }>