Files
bim_engine/src/components/measure-dock-panel/index.ts

583 lines
23 KiB
TypeScript

import './index.css';
import type { ThemeConfig } from '../../themes/types';
import { IBimComponent } from '../../types/component';
import { t } from '../../services/locale';
import { MEASURE_TYPES, type ClearHeightDirection, type ClearHeightSelectType, type MeasureMode } from '../../types/measure';
import { getIcon } from '../../utils/icon-manager';
import type { MeasureConfig, MeasurePrecision, MeasureUnit } from '../measure-panel/types';
const PRIMARY_MODES: MeasureMode[] = ['distance', 'clearHeight', 'clearDistance', 'elevation'];
const SECONDARY_MODES: MeasureMode[] = ['point', 'angle', 'area', 'slope'];
const CONFIG_CACHE_KEY = 'bim-engine:measure:config';
const DEFAULT_CONFIG: MeasureConfig = {
unit: 'mm',
precision: 2
};
export interface MeasureDockPanelOptions {
defaultMode?: MeasureMode;
defaultExpanded?: boolean;
defaultClearHeightDirection?: ClearHeightDirection;
defaultClearHeightSelectType?: ClearHeightSelectType;
onModeChange?: (mode: MeasureMode) => void;
onClearAll?: () => void;
onSettings?: () => void;
onConfigSave?: (config: MeasureConfig) => void;
onClearHeightDirectionChange?: (direction: ClearHeightDirection) => void;
onClearHeightSelectTypeChange?: (selectType: ClearHeightSelectType) => void;
}
export class MeasureDockPanel implements IBimComponent {
public readonly element: HTMLElement;
private readonly options: MeasureDockPanelOptions;
private readonly modeButtons: Map<MeasureMode, HTMLButtonElement> = new Map();
private readonly clearBtn: HTMLButtonElement;
private readonly expandBtn: HTMLButtonElement;
private readonly settingsBtn: HTMLButtonElement;
private readonly secondaryRow: HTMLElement;
private readonly clearHeightOptions: HTMLElement;
private readonly clearHeightDirectionLabel: HTMLElement;
private readonly clearHeightSelectTypeLabel: HTMLElement;
private readonly directionButtons: Map<ClearHeightDirection, HTMLButtonElement> = new Map();
private readonly selectTypeButtons: Map<ClearHeightSelectType, HTMLButtonElement> = new Map();
private readonly mainView: HTMLElement;
private readonly settingsView: HTMLElement;
private readonly settingsUnitLabel: HTMLElement;
private readonly settingsPrecisionLabel: HTMLElement;
private readonly settingsUnitSelect: HTMLSelectElement;
private readonly settingsPrecisionSelect: HTMLSelectElement;
private readonly settingsSaveBtn: HTMLButtonElement;
private readonly settingsBackBtn: HTMLButtonElement;
private activeMode: MeasureMode;
private isExpanded: boolean;
private clearHeightDirection: ClearHeightDirection;
private clearHeightSelectType: ClearHeightSelectType;
private view: 'main' | 'settings' = 'main';
private config: MeasureConfig;
private lockedWidthPx: number | null = null;
constructor(options: MeasureDockPanelOptions = {}) {
this.options = options;
this.activeMode = options.defaultMode ?? 'distance';
this.isExpanded = options.defaultExpanded ?? false;
this.clearHeightDirection = options.defaultClearHeightDirection ?? 1;
this.clearHeightSelectType = options.defaultClearHeightSelectType ?? 'point';
this.config = this.loadConfigFromCache() ?? { ...DEFAULT_CONFIG };
const {
root,
clearBtn,
expandBtn,
settingsBtn,
secondaryRow,
clearHeightOptions,
clearHeightDirectionLabel,
clearHeightSelectTypeLabel,
mainView,
settingsView,
settingsUnitLabel,
settingsPrecisionLabel,
settingsUnitSelect,
settingsPrecisionSelect,
settingsSaveBtn,
settingsBackBtn
} = this.createDom();
this.element = root;
this.clearBtn = clearBtn;
this.expandBtn = expandBtn;
this.settingsBtn = settingsBtn;
this.secondaryRow = secondaryRow;
this.clearHeightOptions = clearHeightOptions;
this.clearHeightDirectionLabel = clearHeightDirectionLabel;
this.clearHeightSelectTypeLabel = clearHeightSelectTypeLabel;
this.mainView = mainView;
this.settingsView = settingsView;
this.settingsUnitLabel = settingsUnitLabel;
this.settingsPrecisionLabel = settingsPrecisionLabel;
this.settingsUnitSelect = settingsUnitSelect;
this.settingsPrecisionSelect = settingsPrecisionSelect;
this.settingsSaveBtn = settingsSaveBtn;
this.settingsBackBtn = settingsBackBtn;
}
public init(): void {
this.setLocales();
this.syncSettingsFormFromConfig();
this.applyExpandedState();
this.applyClearHeightOptionsState();
this.applyViewState();
this.syncActiveMode(this.activeMode);
}
public setTheme(theme: ThemeConfig): void {
const style = this.element.style;
style.setProperty('--bim-text-primary', theme.textPrimary);
style.setProperty('--bim-text-secondary', theme.textSecondary);
style.setProperty('--bim-text-tertiary', theme.textTertiary);
style.setProperty('--bim-border-default', theme.borderDefault);
style.setProperty('--bim-border-strong', theme.borderStrong);
style.setProperty('--bim-bg-inset', theme.bgInset);
style.setProperty('--bim-bg-elevated', theme.bgElevated);
style.setProperty('--bim-primary', theme.primary);
style.setProperty('--bim-primary-subtle', theme.primarySubtle);
style.setProperty('--bim-danger', theme.danger);
style.setProperty('--bim-component-bg-hover', theme.componentBgHover);
}
public setLocales(): void {
for (const [mode, btn] of this.modeButtons.entries()) {
const text = t(`measure.modes.${mode}`);
btn.dataset.tooltip = text;
btn.setAttribute('aria-label', text);
}
const clearText = t('measure.actions.clearAll');
this.clearBtn.dataset.tooltip = clearText;
this.clearBtn.setAttribute('aria-label', clearText);
const expandText = this.isExpanded ? t('measure.actions.collapse') : t('measure.actions.expand');
delete this.expandBtn.dataset.tooltip;
this.expandBtn.setAttribute('aria-label', expandText);
const settingsText = t('measure.actions.settings');
this.settingsBtn.dataset.tooltip = settingsText;
this.settingsBtn.setAttribute('aria-label', settingsText);
this.clearHeightDirectionLabel.textContent = t('measure.clearHeight.direction');
this.clearHeightSelectTypeLabel.textContent = t('measure.clearHeight.selectType');
this.directionButtons.get(0)!.textContent = t('measure.clearHeight.directionDown');
this.directionButtons.get(1)!.textContent = t('measure.clearHeight.directionUp');
this.selectTypeButtons.get('point')!.textContent = t('measure.clearHeight.selectPoint');
this.selectTypeButtons.get('element')!.textContent = t('measure.clearHeight.selectElement');
this.settingsUnitLabel.textContent = t('measure.settings.unit');
this.settingsPrecisionLabel.textContent = t('measure.settings.precision');
this.settingsSaveBtn.textContent = t('measure.settings.save');
this.settingsBackBtn.textContent = t('measure.settings.cancel');
}
public switchMode(mode: MeasureMode, triggerCallback: boolean = true): void {
this.activeMode = mode;
this.syncActiveMode(mode);
this.applyClearHeightOptionsState();
this.closeSettingsView();
if (triggerCallback) {
this.options.onModeChange?.(mode);
}
if (mode === 'clearHeight') {
this.options.onClearHeightDirectionChange?.(this.clearHeightDirection);
this.options.onClearHeightSelectTypeChange?.(this.clearHeightSelectType);
}
}
public destroy(): void {
this.element.remove();
}
public getConfig(): MeasureConfig {
return { ...this.config };
}
private createDom(): {
root: HTMLElement;
clearBtn: HTMLButtonElement;
expandBtn: HTMLButtonElement;
settingsBtn: HTMLButtonElement;
secondaryRow: HTMLElement;
clearHeightOptions: HTMLElement;
clearHeightDirectionLabel: HTMLElement;
clearHeightSelectTypeLabel: HTMLElement;
mainView: HTMLElement;
settingsView: HTMLElement;
settingsUnitLabel: HTMLElement;
settingsPrecisionLabel: HTMLElement;
settingsUnitSelect: HTMLSelectElement;
settingsPrecisionSelect: HTMLSelectElement;
settingsSaveBtn: HTMLButtonElement;
settingsBackBtn: HTMLButtonElement;
} {
const root = document.createElement('div');
root.className = 'measure-dock-panel';
const mainView = document.createElement('div');
mainView.className = 'measure-dock-panel-main';
const clearHeightOptions = document.createElement('div');
clearHeightOptions.className = 'measure-dock-clearheight-options';
const directionGroup = document.createElement('div');
directionGroup.className = 'measure-dock-clearheight-group';
const clearHeightDirectionLabel = document.createElement('span');
clearHeightDirectionLabel.className = 'measure-dock-clearheight-label';
const directionButtons = document.createElement('div');
directionButtons.className = 'measure-dock-clearheight-buttons';
const directionDown = this.createClearHeightOptionButton(() => {
this.setClearHeightDirection(0);
});
const directionUp = this.createClearHeightOptionButton(() => {
this.setClearHeightDirection(1);
});
this.directionButtons.set(0, directionDown);
this.directionButtons.set(1, directionUp);
directionButtons.appendChild(directionDown);
directionButtons.appendChild(directionUp);
directionGroup.appendChild(clearHeightDirectionLabel);
directionGroup.appendChild(directionButtons);
const selectTypeGroup = document.createElement('div');
selectTypeGroup.className = 'measure-dock-clearheight-group';
const clearHeightSelectTypeLabel = document.createElement('span');
clearHeightSelectTypeLabel.className = 'measure-dock-clearheight-label';
const selectTypeButtons = document.createElement('div');
selectTypeButtons.className = 'measure-dock-clearheight-buttons';
const selectPoint = this.createClearHeightOptionButton(() => {
this.setClearHeightSelectType('point');
});
const selectElement = this.createClearHeightOptionButton(() => {
this.setClearHeightSelectType('element');
});
this.selectTypeButtons.set('point', selectPoint);
this.selectTypeButtons.set('element', selectElement);
selectTypeButtons.appendChild(selectPoint);
selectTypeButtons.appendChild(selectElement);
selectTypeGroup.appendChild(clearHeightSelectTypeLabel);
selectTypeGroup.appendChild(selectTypeButtons);
clearHeightOptions.appendChild(directionGroup);
clearHeightOptions.appendChild(selectTypeGroup);
const top = document.createElement('div');
top.className = 'measure-dock-panel-top';
const modeZone = document.createElement('div');
modeZone.className = 'measure-dock-panel-mode-zone';
const primaryRow = document.createElement('div');
primaryRow.className = 'measure-dock-panel-mode-row';
PRIMARY_MODES.forEach((mode) => {
primaryRow.appendChild(this.createModeButton(mode));
});
const secondaryRow = document.createElement('div');
secondaryRow.className = 'measure-dock-panel-mode-row measure-dock-panel-mode-row-secondary';
SECONDARY_MODES.forEach((mode) => {
secondaryRow.appendChild(this.createModeButton(mode));
});
const clearBtn = this.createIconButton('measure-dock-panel-action-clear', getIcon('delete'));
clearBtn.addEventListener('click', () => {
this.options.onClearAll?.();
});
const settingsBtn = this.createIconButton('measure-dock-panel-action-settings', getIcon('settings'));
settingsBtn.addEventListener('click', () => {
this.openSettingsView();
this.options.onSettings?.();
});
primaryRow.appendChild(clearBtn);
secondaryRow.appendChild(settingsBtn);
modeZone.appendChild(primaryRow);
modeZone.appendChild(secondaryRow);
const actionZone = document.createElement('div');
actionZone.className = 'measure-dock-panel-actions';
const expandBtn = this.createIconButton('measure-dock-panel-action-expand', getIcon('expand'));
expandBtn.addEventListener('click', () => {
this.isExpanded = !this.isExpanded;
this.applyExpandedState();
this.setLocales();
});
actionZone.appendChild(expandBtn);
top.appendChild(modeZone);
top.appendChild(actionZone);
mainView.appendChild(clearHeightOptions);
mainView.appendChild(top);
const {
settingsView,
settingsUnitLabel,
settingsPrecisionLabel,
settingsUnitSelect,
settingsPrecisionSelect,
settingsSaveBtn,
settingsBackBtn
} = this.createSettingsView();
root.appendChild(mainView);
root.appendChild(settingsView);
return {
root,
clearBtn,
expandBtn,
settingsBtn,
secondaryRow,
clearHeightOptions,
clearHeightDirectionLabel,
clearHeightSelectTypeLabel,
mainView,
settingsView,
settingsUnitLabel,
settingsPrecisionLabel,
settingsUnitSelect,
settingsPrecisionSelect,
settingsSaveBtn,
settingsBackBtn
};
}
private createSettingsView(): {
settingsView: HTMLElement;
settingsUnitLabel: HTMLElement;
settingsPrecisionLabel: HTMLElement;
settingsUnitSelect: HTMLSelectElement;
settingsPrecisionSelect: HTMLSelectElement;
settingsSaveBtn: HTMLButtonElement;
settingsBackBtn: HTMLButtonElement;
} {
const settingsView = document.createElement('div');
settingsView.className = 'measure-dock-panel-settings';
const unitRow = document.createElement('div');
unitRow.className = 'measure-dock-settings-row';
const settingsUnitLabel = document.createElement('span');
settingsUnitLabel.className = 'measure-dock-settings-label';
const settingsUnitSelect = document.createElement('select');
settingsUnitSelect.className = 'measure-dock-settings-select';
['m', 'cm', 'mm', 'km'].forEach((unit) => {
const option = document.createElement('option');
option.value = unit;
option.textContent = unit;
settingsUnitSelect.appendChild(option);
});
unitRow.appendChild(settingsUnitLabel);
unitRow.appendChild(settingsUnitSelect);
const precisionRow = document.createElement('div');
precisionRow.className = 'measure-dock-settings-row';
const settingsPrecisionLabel = document.createElement('span');
settingsPrecisionLabel.className = 'measure-dock-settings-label';
const settingsPrecisionSelect = document.createElement('select');
settingsPrecisionSelect.className = 'measure-dock-settings-select';
[0, 1, 2, 3].forEach((precision) => {
const option = document.createElement('option');
option.value = String(precision);
option.textContent = precision === 0 ? '0' : `0.${'0'.repeat(precision)}`;
settingsPrecisionSelect.appendChild(option);
});
precisionRow.appendChild(settingsPrecisionLabel);
precisionRow.appendChild(settingsPrecisionSelect);
const actions = document.createElement('div');
actions.className = 'measure-dock-settings-actions';
const settingsSaveBtn = document.createElement('button');
settingsSaveBtn.type = 'button';
settingsSaveBtn.className = 'measure-dock-settings-btn is-save';
settingsSaveBtn.addEventListener('click', () => {
this.saveSettings();
});
const settingsBackBtn = document.createElement('button');
settingsBackBtn.type = 'button';
settingsBackBtn.className = 'measure-dock-settings-btn is-back';
settingsBackBtn.addEventListener('click', () => {
this.closeSettingsView();
});
actions.appendChild(settingsSaveBtn);
actions.appendChild(settingsBackBtn);
settingsView.appendChild(unitRow);
settingsView.appendChild(precisionRow);
settingsView.appendChild(actions);
return {
settingsView,
settingsUnitLabel,
settingsPrecisionLabel,
settingsUnitSelect,
settingsPrecisionSelect,
settingsSaveBtn,
settingsBackBtn
};
}
private createClearHeightOptionButton(onClick: () => void): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'measure-dock-clearheight-btn';
button.addEventListener('click', onClick);
return button;
}
private createModeButton(mode: MeasureMode): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'measure-dock-panel-mode-btn';
button.dataset.mode = mode;
button.innerHTML = `<span class="measure-dock-panel-mode-icon">${MEASURE_TYPES[mode].icon}</span>`;
button.addEventListener('click', () => {
this.switchMode(mode);
});
this.modeButtons.set(mode, button);
return button;
}
private createIconButton(className: string, iconSvg: string): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = `measure-dock-panel-action-btn ${className}`;
button.innerHTML = iconSvg;
return button;
}
private applyExpandedState(): void {
this.secondaryRow.style.display = this.isExpanded ? 'grid' : 'none';
this.expandBtn.classList.toggle('is-expanded', this.isExpanded);
this.expandBtn.classList.toggle('is-collapsed', !this.isExpanded);
}
private openSettingsView(): void {
this.lockPanelWidth();
this.view = 'settings';
this.syncSettingsFormFromConfig();
this.applyViewState();
}
private closeSettingsView(): void {
if (this.view !== 'settings') {
return;
}
this.view = 'main';
this.unlockPanelWidth();
this.applyViewState();
}
private saveSettings(): void {
const nextUnit = this.settingsUnitSelect.value as MeasureUnit;
const nextPrecision = Number(this.settingsPrecisionSelect.value) as MeasurePrecision;
if (!this.isValidUnit(nextUnit) || !this.isValidPrecision(nextPrecision)) {
return;
}
this.config = {
unit: nextUnit,
precision: nextPrecision
};
this.saveConfigToCache(this.config);
this.options.onConfigSave?.(this.getConfig());
this.view = 'main';
this.unlockPanelWidth();
this.applyViewState();
}
private lockPanelWidth(): void {
const width = this.element.getBoundingClientRect().width;
if (width <= 0) {
return;
}
this.lockedWidthPx = Math.ceil(width);
this.element.style.width = `${this.lockedWidthPx}px`;
}
private unlockPanelWidth(): void {
this.lockedWidthPx = null;
this.element.style.removeProperty('width');
}
private syncSettingsFormFromConfig(): void {
this.settingsUnitSelect.value = this.config.unit;
this.settingsPrecisionSelect.value = String(this.config.precision);
}
private applyViewState(): void {
const showMain = this.view === 'main';
this.mainView.style.display = showMain ? 'block' : 'none';
this.settingsView.style.display = showMain ? 'none' : 'flex';
}
private applyClearHeightOptionsState(): void {
// this.clearHeightOptions.classList.toggle('is-visible', this.activeMode === 'clearHeight');
this.clearHeightOptions.classList.remove('is-visible');
for (const [direction, button] of this.directionButtons.entries()) {
button.classList.toggle('is-active', direction === this.clearHeightDirection);
}
for (const [selectType, button] of this.selectTypeButtons.entries()) {
button.classList.toggle('is-active', selectType === this.clearHeightSelectType);
}
}
private setClearHeightDirection(direction: ClearHeightDirection): void {
if (this.clearHeightDirection === direction) {
return;
}
this.clearHeightDirection = direction;
this.applyClearHeightOptionsState();
this.options.onClearHeightDirectionChange?.(direction);
}
private setClearHeightSelectType(selectType: ClearHeightSelectType): void {
if (this.clearHeightSelectType === selectType) {
return;
}
this.clearHeightSelectType = selectType;
this.applyClearHeightOptionsState();
this.options.onClearHeightSelectTypeChange?.(selectType);
}
private loadConfigFromCache(): MeasureConfig | null {
try {
const raw = localStorage.getItem(CONFIG_CACHE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as Partial<MeasureConfig>;
if (!parsed || typeof parsed !== 'object') {
return null;
}
if (!this.isValidUnit(parsed.unit) || !this.isValidPrecision(parsed.precision)) {
return null;
}
return {
unit: parsed.unit,
precision: parsed.precision
};
} catch {
return null;
}
}
private saveConfigToCache(config: MeasureConfig): void {
try {
localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config));
} catch {
return;
}
}
private isValidUnit(unit: unknown): unit is MeasureUnit {
return unit === 'm' || unit === 'cm' || unit === 'mm' || unit === 'km';
}
private isValidPrecision(precision: unknown): precision is MeasurePrecision {
return precision === 0 || precision === 1 || precision === 2 || precision === 3;
}
private syncActiveMode(mode: MeasureMode): void {
for (const [key, button] of this.modeButtons.entries()) {
button.classList.toggle('is-active', key === mode);
}
}
}