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

569 lines
19 KiB
TypeScript
Raw Normal View History

2025-12-22 18:48:38 +08:00
import './index.css';
import type { ThemeConfig } from '../../themes/types';
import { IBimComponent } from '../../types/component';
import { localeManager, t } from '../../services/locale';
import { themeManager } from '../../services/theme';
import type { MeasureMode, MeasurePanelOptions, MeasureResult } from './types';
/**
* UI
*
*
* - 8 4 /
* - current mode
* - setResult
* - / UI /
*
*
* - t(key)
* - / destroy
*/
export class MeasurePanel implements IBimComponent {
public element: HTMLElement;
private options: MeasurePanelOptions;
private activeMode: MeasureMode;
private isExpanded: boolean;
private result: MeasureResult | null = null;
// DOM 引用(便于局部更新,减少频繁 querySelector
private toolButtons: Map<MeasureMode, HTMLButtonElement> = new Map();
private toggleBtn!: HTMLButtonElement;
private toggleTextEl!: HTMLElement;
private currentModeValueEl!: HTMLElement;
private mainValueValueEl!: HTMLElement;
private mainValueLabelEl!: HTMLElement;
private xyzXEl!: HTMLElement;
private xyzYEl!: HTMLElement;
private xyzZEl!: HTMLElement;
private clearBtn!: HTMLButtonElement;
private settingsBtn!: HTMLButtonElement;
// 订阅清理
private unsubscribeLocale: (() => void) | null = null;
private unsubscribeTheme: (() => void) | null = null;
/**
*
* @param options
*/
constructor(options: MeasurePanelOptions = {}) {
this.options = options;
this.activeMode = options.defaultMode ?? 'distance';
this.isExpanded = options.defaultExpanded ?? false;
this.element = this.createDom();
}
/**
* IBimComponent
*/
public init(): void {
// 订阅语言变更:更新所有文本/提示
this.unsubscribeLocale = localeManager.subscribe(() => {
this.setLocales();
});
// 订阅主题变更:更新 CSS 变量(如需要)
this.unsubscribeTheme = themeManager.subscribe((theme) => {
this.setTheme(theme);
});
// 初始应用
this.setLocales();
this.setTheme(themeManager.getTheme());
// 初始渲染状态(按钮显隐、选中态、结果区)
this.applyExpandedState();
this.applyActiveModeState();
this.renderResult();
}
/**
* IBimComponent
* @param theme
*/
public setTheme(theme: ThemeConfig): void {
// 为了可读性:这里显式写出映射,不做过度抽象
const style = this.element.style;
// 这些变量不会强制覆盖外部Dialog已有变量只做兜底
style.setProperty('--bim-measure-border', theme.border ?? 'rgba(255, 255, 255, 0.12)');
style.setProperty('--bim-measure-divider', theme.border ?? 'rgba(255, 255, 255, 0.10)');
style.setProperty('--bim-measure-icon-color', theme.icon ?? '#ddd');
style.setProperty('--bim-measure-label-color', theme.textSecondary ?? 'rgba(255, 255, 255, 0.70)');
style.setProperty('--bim-measure-value-color', theme.textPrimary ?? 'rgba(255, 255, 255, 0.90)');
// “删除全部”颜色:截图中偏绿色,这里用 primary 做一个合理映射
style.setProperty('--bim-measure-danger', theme.primary ?? '#46d369');
style.setProperty('--bim-measure-btn-bg', theme.componentBackground ?? 'rgba(255, 255, 255, 0.06)');
style.setProperty('--bim-measure-btn-hover-bg', theme.componentHover ?? 'rgba(255, 255, 255, 0.10)');
style.setProperty('--bim-measure-btn-active-bg', theme.componentActive ?? 'rgba(255, 255, 255, 0.14)');
}
/**
* IBimComponent
*/
public setLocales(): void {
// 1) 更新按钮 tooltip图标占位时tooltip 是主要的可读文本)
for (const [mode, btn] of this.toolButtons.entries()) {
btn.title = t(this.getModeI18nKey(mode));
btn.setAttribute('aria-label', btn.title);
}
// 2) 更新展开/收起按钮 tooltip
this.toggleBtn.title = this.isExpanded ? t('measure.actions.collapse') : t('measure.actions.expand');
this.toggleBtn.setAttribute('aria-label', this.toggleBtn.title);
// 2.1) 更新展开/收起按钮可见文本(你要求的“文字提示”)
if (this.toggleTextEl) {
this.toggleTextEl.textContent = this.toggleBtn.title;
}
// 3) 更新底部按钮文本/tooltip
this.clearBtn.textContent = t('measure.actions.clearAll');
this.settingsBtn.title = t('measure.actions.settings');
this.settingsBtn.setAttribute('aria-label', this.settingsBtn.title);
// 4) 更新“当前方式”显示value
this.currentModeValueEl.textContent = t(this.getModeI18nKey(this.activeMode));
// 5) 主值 label随模式变化
this.mainValueLabelEl.textContent = t(this.getModeValueLabelI18nKey(this.activeMode));
// 6) XYZ label使用 key
// 这里 label 在 createDom 已经是固定文本节点,直接用 setText 更新更直观
// 但为了减少 DOM 结构复杂度,我们把 label 写在 createDom 里,通过 data-key 更新
const labelNodes = this.element.querySelectorAll<HTMLElement>('[data-i18n-key]');
labelNodes.forEach((node) => {
const key = node.dataset.i18nKey;
if (key) node.textContent = t(key);
});
}
/**
* IBimComponent
*/
public destroy(): void {
// 清理订阅
if (this.unsubscribeLocale) {
this.unsubscribeLocale();
this.unsubscribeLocale = null;
}
if (this.unsubscribeTheme) {
this.unsubscribeTheme();
this.unsubscribeTheme = null;
}
// 清理事件监听:由于本组件的监听都绑定在创建时的具体按钮上,
// 且按钮会随 element 一起被 GC这里不做逐个 removeEventListener可读性优先
// 移除 DOM
this.element.remove();
}
// ==========================
// 对外 API给 Manager / 外部业务调用)
// ==========================
/**
*
*/
public getActiveMode(): MeasureMode {
return this.activeMode;
}
/**
*
* @param mode
*/
public switchMode(mode: MeasureMode): void {
this.setActiveMode(mode);
}
/**
*
* @param mode
*/
public setActiveMode(mode: MeasureMode): void {
if (this.activeMode === mode) return;
this.activeMode = mode;
this.applyActiveModeState();
// 切换方式后,主值 label 也需要更新
this.mainValueLabelEl.textContent = t(this.getModeValueLabelI18nKey(this.activeMode));
this.currentModeValueEl.textContent = t(this.getModeI18nKey(this.activeMode));
// 通知外部(如果需要)
if (this.options.onModeChange) {
this.options.onModeChange(mode);
}
// 模式切换后,结果展示也应刷新(例如某些字段显示为 --
this.renderResult();
}
/**
*
* @param result null
*/
public setResult(result: MeasureResult | null): void {
this.result = result;
this.renderResult();
}
/**
* UI +
*/
public clearAll(): void {
// 先清空结果显示
this.result = null;
this.renderResult();
// 通知外部
if (this.options.onClearAll) {
this.options.onClearAll();
}
}
/**
* /
*/
public openSettings(): void {
if (this.options.onSettings) {
this.options.onSettings();
return;
}
// 兜底:避免无声失败,打印中文日志(符合项目规范)
console.warn('[MeasurePanel] 未提供设置回调 onSettings当前仅预留接口。');
}
/**
* /
* @param expanded
*/
public setExpanded(expanded: boolean): void {
if (this.isExpanded === expanded) return;
this.isExpanded = expanded;
this.applyExpandedState();
this.setLocales(); // 更新 tooltip展开/收起)
// 通知外部:用于重新计算 Dialog 高度
if (this.options.onExpandedChange) {
this.options.onExpandedChange(this.isExpanded);
}
}
/**
*
*/
public getExpanded(): boolean {
return this.isExpanded;
}
// ==========================
// 内部实现
// ==========================
private createDom(): HTMLElement {
const root = document.createElement('div');
root.className = 'bim-measure-panel';
// 顶部:工具按钮区
const toolsBox = document.createElement('div');
toolsBox.className = 'bim-measure-tools';
const grid = document.createElement('div');
grid.className = 'bim-measure-tool-grid';
// 8 种测量方式(顺序严格按你给的)
const modes: MeasureMode[] = [
'distance',
'minDistance',
'angle',
'elevation',
'volume',
'laserDistance',
'slope',
'spaceVolume'
];
// 图标占位:统一用圆形(你要求的“圆形占位”)
const circleIconSvg = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
</svg>
`;
// 逐个创建按钮
for (let i = 0; i < modes.length; i++) {
const mode = modes[i];
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bim-measure-tool-btn';
btn.dataset.mode = mode;
// icon
const icon = document.createElement('span');
icon.className = 'bim-measure-tool-icon';
icon.innerHTML = circleIconSvg;
btn.appendChild(icon);
// 点击切换模式
btn.addEventListener('click', () => {
this.setActiveMode(mode);
});
// 先不在这里设置 title/text统一交给 setLocales
this.toolButtons.set(mode, btn);
grid.appendChild(btn);
}
toolsBox.appendChild(grid);
// 展开/收起按钮(箭头)
const toggleBox = document.createElement('div');
toggleBox.className = 'bim-measure-toggle';
this.toggleBtn = document.createElement('button');
this.toggleBtn.type = 'button';
this.toggleBtn.className = 'bim-measure-toggle-btn';
// 展开/收起按钮:更小,并带文字提示(展开/收起)
// 注意:文本内容由 setLocales() 统一更新,这里先放一个占位容器
this.toggleTextEl = document.createElement('span');
this.toggleTextEl.className = 'bim-measure-toggle-text';
const toggleIconEl = document.createElement('span');
toggleIconEl.className = 'bim-measure-toggle-icon';
toggleIconEl.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 10l5 5 5-5z"></path>
</svg>
`;
this.toggleBtn.appendChild(this.toggleTextEl);
this.toggleBtn.appendChild(toggleIconEl);
this.toggleBtn.addEventListener('click', () => {
this.isExpanded = !this.isExpanded;
this.applyExpandedState();
this.setLocales(); // 更新 tooltip展开/收起)
// 通知外部:用于重新计算 Dialog 高度
if (this.options.onExpandedChange) {
this.options.onExpandedChange(this.isExpanded);
}
});
toggleBox.appendChild(this.toggleBtn);
toolsBox.appendChild(toggleBox);
root.appendChild(toolsBox);
// 中部:结果区
const resultBox = document.createElement('div');
resultBox.className = 'bim-measure-result';
// 当前方式
const currentModeRow = document.createElement('div');
currentModeRow.className = 'bim-measure-row';
const currentModeLabel = document.createElement('span');
currentModeLabel.className = 'label';
currentModeLabel.dataset.i18nKey = 'measure.labels.currentMode';
const currentModeValue = document.createElement('span');
currentModeValue.className = 'value';
this.currentModeValueEl = currentModeValue;
currentModeRow.appendChild(currentModeLabel);
currentModeRow.appendChild(currentModeValue);
resultBox.appendChild(currentModeRow);
// 主结果值(随模式变化)
const mainValueRow = document.createElement('div');
mainValueRow.className = 'bim-measure-row';
const mainValueLabel = document.createElement('span');
mainValueLabel.className = 'label';
this.mainValueLabelEl = mainValueLabel;
const mainValueValue = document.createElement('span');
mainValueValue.className = 'value';
this.mainValueValueEl = mainValueValue;
mainValueRow.appendChild(mainValueLabel);
mainValueRow.appendChild(mainValueValue);
resultBox.appendChild(mainValueRow);
// XYZ
const xyzBox = document.createElement('div');
xyzBox.className = 'bim-measure-xyz';
const makeXyzRow = (labelKey: string, valueElSetter: (el: HTMLElement) => void) => {
const row = document.createElement('div');
row.className = 'bim-measure-row';
const label = document.createElement('span');
label.className = 'label';
label.dataset.i18nKey = labelKey;
const value = document.createElement('span');
value.className = 'value';
valueElSetter(value);
row.appendChild(label);
row.appendChild(value);
return row;
};
xyzBox.appendChild(makeXyzRow('measure.labels.x', (el) => (this.xyzXEl = el)));
xyzBox.appendChild(makeXyzRow('measure.labels.y', (el) => (this.xyzYEl = el)));
xyzBox.appendChild(makeXyzRow('measure.labels.z', (el) => (this.xyzZEl = el)));
resultBox.appendChild(xyzBox);
root.appendChild(resultBox);
// 底部:删除全部 + 设置
const footer = document.createElement('div');
footer.className = 'bim-measure-footer';
this.clearBtn = document.createElement('button');
this.clearBtn.type = 'button';
this.clearBtn.className = 'bim-measure-clear-btn';
this.clearBtn.addEventListener('click', () => {
this.clearAll();
});
this.settingsBtn = document.createElement('button');
this.settingsBtn.type = 'button';
this.settingsBtn.className = 'bim-measure-settings-btn';
this.settingsBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.06-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.27 7.27 0 0 0-1.63-.94l-.36-2.54A.5.5 0 0 0 13.9 1h-3.8a.5.5 0 0 0-.49.42l-.36 2.54c-.58.23-1.12.54-1.63.94l-2.39-.96a.5.5 0 0 0-.6.22L2.71 7.48a.5.5 0 0 0 .12.64l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94L2.83 14.52a.5.5 0 0 0-.12.64l1.92 3.32c.13.22.39.3.6.22l2.39-.96c.5.4 1.05.71 1.63.94l.36 2.54c.04.24.25.42.49.42h3.8c.24 0 .45-.18.49-.42l.36-2.54c.58-.23 1.12-.54 1.63-.94l2.39.96c.22.09.47 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58zM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5z"></path>
</svg>
`;
this.settingsBtn.addEventListener('click', () => {
this.openSettings();
});
footer.appendChild(this.clearBtn);
footer.appendChild(this.settingsBtn);
root.appendChild(footer);
return root;
}
/**
* / 4
*/
private applyExpandedState(): void {
let index = 0;
for (const btn of this.toolButtons.values()) {
// 默认展示前四个,其余根据展开状态显示/隐藏
if (index >= 4) {
btn.style.display = this.isExpanded ? '' : 'none';
} else {
btn.style.display = '';
}
index++;
}
// toggle 样式(旋转箭头)
if (this.isExpanded) {
this.toggleBtn.classList.add('is-expanded');
} else {
this.toggleBtn.classList.remove('is-expanded');
}
}
/**
*
*/
private applyActiveModeState(): void {
for (const [mode, btn] of this.toolButtons.entries()) {
if (mode === this.activeMode) {
btn.classList.add('is-active');
} else {
btn.classList.remove('is-active');
}
}
}
/**
* activeMode result
*/
private renderResult(): void {
// 1) 主值
const mainText = this.formatMainValue(this.activeMode, this.result);
this.mainValueValueEl.textContent = mainText;
// 2) XYZ
const xyz = this.result?.xyz;
if (!xyz) {
this.xyzXEl.textContent = '--';
this.xyzYEl.textContent = '--';
this.xyzZEl.textContent = '--';
return;
}
// 为了可读性:这里不做 fancy formatter只做基础展示
this.xyzXEl.textContent = this.formatNumber(xyz.x);
this.xyzYEl.textContent = this.formatNumber(xyz.y);
this.xyzZEl.textContent = this.formatNumber(xyz.z);
}
/**
* key
*/
private getModeI18nKey(mode: MeasureMode): string {
return `measure.modes.${mode}`;
}
/**
* label key
*/
private getModeValueLabelI18nKey(mode: MeasureMode): string {
return `measure.labels.value.${mode}`;
}
/**
*
* @param mode
* @param result
*/
private formatMainValue(mode: MeasureMode, result: MeasureResult | null): string {
if (!result) return '--';
// 根据不同 mode 读取对应字段并格式化单位
// 单位文本也走国际化(可替换为英文/中文)
switch (mode) {
case 'distance':
return this.formatWithUnit(result.distanceMm, 'measure.units.mm');
case 'minDistance':
return this.formatWithUnit(result.minDistanceMm, 'measure.units.mm');
case 'angle':
return this.formatWithUnit(result.angleDeg, 'measure.units.deg');
case 'elevation':
return this.formatWithUnit(result.elevationMm, 'measure.units.mm');
case 'volume':
return this.formatWithUnit(result.volumeM3, 'measure.units.m3');
case 'laserDistance':
return this.formatWithUnit(result.laserDistanceMm, 'measure.units.mm');
case 'slope':
return this.formatWithUnit(result.slopePercent, 'measure.units.percent');
case 'spaceVolume':
return this.formatWithUnit(result.spaceVolumeM3, 'measure.units.m3');
default:
return '--';
}
}
/**
* +
*/
private formatWithUnit(value: number | undefined, unitKey: string): string {
if (value === null || value === undefined || Number.isNaN(value)) return '--';
return `${this.formatNumber(value)} ${t(unitKey)}`;
}
/**
*
*/
private formatNumber(value: number): string {
// 保留 3 位小数以内(简单策略:整数不带小数,非整数保留到 3 位)
if (Number.isInteger(value)) return String(value);
return value.toFixed(3).replace(/0+$/g, '').replace(/\.$/g, '');
}
}