Files
bim_engine/src/components/measure-panel/index.ts
yuding 19f7e3ffbc feat(theme): 重构主题系统,新增 glass-pill 按钮样式
- ThemeConfig 接口扩展至 60+ 语义化属性
- 新增深浅主题预设 (glassPill overrides)
- button-group 支持 glass-pill 样式变体
- 默认主题改为浅色
- 移除 toolbar 容器硬编码定位
- 统一组件 CSS 变量命名规范
- 暂时隐藏下拉箭头
2026-01-21 15:50:07 +08:00

1016 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { MeasureConfig, MeasureMode, MeasurePanelOptions, MeasurePrecision, MeasureResult, MeasureUnit } from './types';
/**
* 测量方式图标SVG
*
* 说明:
* - 你上传的 SVG 原文件放在 `src/assets/icons/` 目录
* - 原始 SVG 含 defs/clipPath/style/背景 rect直接内联时容易出现渲染/裁剪异常(尤其多个图标同时出现)
* - 这里把图标“瘦身”为纯 path并统一使用 currentColor确保稳定渲染
*/
const MEASURE_MODE_ICON_SVGS: Record<MeasureMode, string> = {
distance: `<svg viewBox="0 0 32 32" aria-hidden="true"><g transform="translate(0 4.197)"><path fill="currentColor" d="M29.692,3.03,27.55.919a.529.529,0,0,1-.014-.756A.549.549,0,0,1,28.3.15l.014.013,3.067,3.023a.529.529,0,0,1,0,.756L28.317,6.966a.549.549,0,0,1-.767.013.529.529,0,0,1-.014-.756l.014-.013L29.692,4.1H2.31L4.452,6.21a.528.528,0,0,1,.013.756.547.547,0,0,1-.766.013l-.014-.013L.616,3.942a.531.531,0,0,1,0-.756L3.685.163a.548.548,0,0,1,.767.014.528.528,0,0,1,0,.742L2.31,3.03ZM24.136,15.055H23.051V18H21.966v-2.94H20.882V18H19.8v-2.94H18.712V18H17.627v-2.94H16.543v5.078H15.458V15.055H14.373V18H13.288v-2.94H12.2V18H11.119v-2.94H10.034V18H8.949v-2.94H7.865V18H6.78v-2.94H5.7v5.078H4.61V15.055H1.9a.27.27,0,0,0-.272.268v6.413A.269.269,0,0,0,1.9,22H30.1a.268.268,0,0,0,.271-.267V15.323a.269.269,0,0,0-.271-.268H27.39v5.078H26.305V15.055H25.221V18H24.136Zm5.966-1.6A1.884,1.884,0,0,1,32,15.323v6.413a1.885,1.885,0,0,1-1.9,1.871H1.9A1.885,1.885,0,0,1,0,21.736V15.323a1.885,1.885,0,0,1,1.9-1.871Z"/></g></svg>`,
minDistance: `<svg viewBox="0 0 32 32" aria-hidden="true"><path fill="currentColor" d="M-5.839,24.8H-34.16A1.875,1.875,0,0,1-36,22.933V16.52a1.887,1.887,0,0,1,1.9-1.871H-5.9A1.887,1.887,0,0,1-4,16.52v6.412A1.875,1.875,0,0,1-5.839,24.8ZM-34.1,16.252a.27.27,0,0,0-.272.268v6.412a.27.27,0,0,0,.272.267H-5.9a.269.269,0,0,0,.271-.267V16.52a.27.27,0,0,0-.271-.268H-8.61V21.33H-9.695V16.252h-1.085v2.939h-1.085V16.252h-1.085v2.939h-1.085V16.252h-1.084v2.939H-16.2V16.252h-1.085v2.939h-1.085V16.252h-1.084V21.33h-1.084V16.252h-1.085v2.939h-1.085V16.252H-23.8v2.939h-1.085V16.252h-1.085v2.939h-1.085V16.252h-1.084v2.939H-29.22V16.252H-30.3V21.33H-31.39V16.252Z" transform="translate(36 2)"/><path fill="currentColor" d="M23.716,7.947V4.875c0-.8-.232-1.085-.765-1.085a1.573,1.573,0,0,0-1.133.585V7.947H20.4V2.75h1.163l.1.687H21.7a2.547,2.547,0,0,1,1.763-.817c1.172,0,1.676.78,1.676,2.089V7.947Zm-7.26,0V2.62h1.58V7.947Zm-3.8,0V4.875c0-.8-.243-1.085-.76-1.085a1.606,1.606,0,0,0-1.049.585V7.947H9.421V4.875c0-.8-.243-1.085-.758-1.085a1.608,1.608,0,0,0-1.05.585V7.947H6.194V2.75H7.36l.1.7H7.5A2.326,2.326,0,0,1,9.169,2.62a1.486,1.486,0,0,1,1.5.91A2.445,2.445,0,0,1,12.4,2.62c1.156,0,1.691.78,1.691,2.089V7.947Zm3.8-6.849a.79.79,0,0,1,1.58,0,.79.79,0,0,1-1.58,0Z" transform="translate(0.333 3.053)"/></svg>`,
angle: `<svg viewBox="0 0 32 32" aria-hidden="true"><path fill="currentColor" d="M39.587,50.766h13.7a1,1,0,0,1,0,2H23.171a1,1,0,0,1,0-2h1.418l6.582-7.006v-.006a.517.517,0,0,1,.14-.357.456.456,0,0,1,.337-.144l12.1-12.876a.451.451,0,0,1,.665,0,.524.524,0,0,1,0,.708L32.883,43.355a8.3,8.3,0,0,1,6.7,7.411Zm-.949,0a7.254,7.254,0,0,0-6.611-6.5l-6.108,6.5Z" transform="translate(-22.229 -26.489)"/></svg>`,
elevation: `<svg viewBox="0 0 32 32" aria-hidden="true"><path fill="currentColor" d="M84.131,193.119a1.056,1.056,0,0,1,1.116.982v7.857a1.056,1.056,0,0,1-1.116.982H54.367a1.056,1.056,0,0,1-1.116-.982V194.1a1.056,1.056,0,0,1,1.116-.982Zm-1.116,1.964H55.483v5.893H83.015Zm1.116-13.749a1.064,1.064,0,0,1,1.114.935,1.032,1.032,0,0,1-1.007,1.025l-.107,0H71.2l-7.858,6.914a1.227,1.227,0,0,1-1.578,0l-8.185-7.2-.018-.016-.032-.031.049.047a1.107,1.107,0,0,1-.092-.092l-.011-.014a.869.869,0,0,1-.182-.857l0-.008L53.31,182l.012-.029.02-.045.019-.035a1.1,1.1,0,0,1,.891-.552h.007q.053,0,.107,0ZM68.043,183.3H57.06l5.492,4.831Z" transform="translate(-53.247 -176.136)"/></svg>`,
volume: `<svg viewBox="0 0 32 32" aria-hidden="true"><path fill="currentColor" d="M94.74,86.658V71.189a.371.371,0,0,1,.2-.329l13.869-7.22a.371.371,0,0,1,.344,0l13.053,6.891h0l.819.431a.371.371,0,0,1,.2.328v15.3a.371.371,0,0,1-.2.328l-13.872,7.255a.371.371,0,0,1-.342,0L94.94,86.987a.371.371,0,0,1-.2-.329Zm2.119-.837,11.2,5.8a.024.024,0,0,0,.035-.022V79.483a.371.371,0,0,0-.2-.328l-11.2-5.909a.024.024,0,0,0-.035.021V85.492A.371.371,0,0,0,96.859,85.821Zm13.151-6.459v12a.12.12,0,0,0,.176.106L114,89.474l3.334-1.745,3.771-1.978a.371.371,0,0,0,.2-.328V73.5a.193.193,0,0,0-.284-.171l-10.812,5.708A.371.371,0,0,0,110.01,79.362ZM97.925,71.725l10.839,5.72a.371.371,0,0,0,.346,0L119.8,71.808a.214.214,0,0,0,0-.378l-10.649-5.621a.371.371,0,0,0-.344,0L97.925,71.47A.144.144,0,0,0,97.925,71.725Z" transform="translate(-92.982 -62.907)"/></svg>`,
laserDistance: `<svg viewBox="0 0 32 32" aria-hidden="true"><g transform="translate(0 -1.293)"><path fill="currentColor" d="M0,1.293v31.96H32V1.293ZM30.97,32.182H1.03V2.323H30.97Z"/><path fill="currentColor" d="M160.026,291.9l1.6,1.6,7.305-7.305-7.305-7.305-1.6,1.6,4.794,4.566h-6.392v2.283h6.392Zm-5.251,0-4.566-4.566h6.164v-2.283H150.21l4.566-4.566-1.37-1.6L146.1,286.19l7.305,7.305Z" transform="translate(-141.535 -268.917)"/></g></svg>`,
slope: `<svg viewBox="0 0 32 32" aria-hidden="true"><path fill="currentColor" d="M202.1,188.337l2.629-2.191-8.447-3.106,1.533,8.871,2.629-2.194,9.341,11.209,1.656-1.379Zm-13.726-.435a1.075,1.075,0,0,0-1.07-.341,1.057,1.057,0,0,0-.5.277l-5.11,4.08a1.08,1.08,0,0,0-.406.84l-.007,17.386a1.079,1.079,0,0,0,1.077,1.077L205.7,211.2a1.078,1.078,0,0,0,.822-1.774Zm-4.934,21.164.007-15.788,3.968-3.171,15.974,18.941Z" transform="translate(-180.36 -181.131)"/></svg>`,
spaceVolume: `<svg viewBox="0 0 32 32" aria-hidden="true"><g transform="translate(-106.35 -97.661)"><path fill="currentColor" d="M125.977,128.829l13.076-7.363v-13.6l-13.076,6.8Zm-3.126-15.655a.565.565,0,0,1-.258-.064L109.3,106.323a.567.567,0,0,1-.011-1L122.578,98a.567.567,0,0,1,.55,0l13.288,7.325a.567.567,0,0,1-.011,1l-13.292,6.79A.63.63,0,0,1,122.851,113.174ZM110.773,105.8l12.078,6.172,12.078-6.172-12.078-6.657Z" transform="translate(-1.922)"/><path fill="currentColor" d="M120.649,322.52a.58.58,0,0,1-.262-.064l-13.08-6.8a.573.573,0,0,1-.307-.5V301a.566.566,0,0,1,.273-.486.573.573,0,0,1,.558-.019l13.076,6.8a.573.573,0,0,1,.307.5v14.161a.57.57,0,0,1-.565.569Zm-12.511-7.708,11.942,6.206V308.136l-11.942-6.206Zm15.917,9.408a.585.585,0,0,1-.288-.076.567.567,0,0,1-.281-.489V309.49a.562.562,0,0,1,.307-.5l13.076-6.8a.573.573,0,0,1,.558.019.562.562,0,0,1,.273.486v13.6a.568.568,0,0,1-.288.493l-13.076,7.359A.557.557,0,0,1,124.055,324.22Zm.569-14.385V322.68l11.942-6.722V303.629Z" transform="translate(0 -194.822)"/></g></svg>`
};
/**
* 测量面板组件(只做 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;
/**
* 测量配置(单位/精度)
* 说明:
* - 你要求:创建 MeasurePanel 不传入单位和精度
* - 默认值维护在组件内部
* - 初始化时优先读取缓存localStorage否则使用默认值
*/
private config: MeasureConfig;
/** 设置面板的临时配置(用于“取消”回滚) */
private draftConfig: MeasureConfig | null = null;
/** 当前视图:主面板 / 设置面板 */
private view: 'main' | 'settings' = 'main';
/** 缓存 key默认全局 */
private static readonly CONFIG_CACHE_KEY = 'bim-engine:measure:config';
/** 默认配置(由组件内部维护) */
private static readonly DEFAULT_CONFIG: MeasureConfig = {
unit: 'mm',
precision: 2
};
// DOM 引用(便于局部更新,减少频繁 querySelector
private toolButtons: Map<MeasureMode, HTMLButtonElement> = new Map();
private toggleBtn!: HTMLButtonElement;
private toggleTextEl!: HTMLElement;
private mainValueValueEl!: HTMLElement;
private mainValueLabelEl!: HTMLElement;
private mainNumberEl!: HTMLElement;
private mainUnitEl!: HTMLElement;
private xyzBoxEl!: HTMLElement;
private xyzXEl!: HTMLElement;
private xyzYEl!: HTMLElement;
private xyzZEl!: HTMLElement;
private clearBtn!: HTMLButtonElement;
private settingsBtn!: HTMLButtonElement;
// Settings DOM
private mainViewEl!: HTMLElement;
private settingsViewEl!: HTMLElement;
private unitSelectEl!: HTMLSelectElement;
private precisionSelectEl!: HTMLSelectElement;
private saveSettingsBtn!: HTMLButtonElement;
private cancelSettingsBtn!: 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.config = this.loadConfigFromCache() ?? { ...MeasurePanel.DEFAULT_CONFIG };
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.applyViewState();
this.renderResult();
// 触发初始测量模式的回调(让外部知道默认激活了哪个模式)
if (this.options.onModeChange) {
this.options.onModeChange(this.activeMode);
}
}
/**
* 设置主题(实现 IBimComponent
* @param theme 主题配置
*/
public setTheme(theme: ThemeConfig): void {
// 为了可读性:这里显式写出映射,不做过度抽象
const style = this.element.style;
// 这些变量不会强制覆盖外部Dialog已有变量只做兜底
style.setProperty('--bim-measure-border', theme.borderDefault ?? 'rgba(255, 255, 255, 0.12)');
style.setProperty('--bim-measure-divider', theme.borderDefault ?? 'rgba(255, 255, 255, 0.10)');
style.setProperty('--bim-measure-icon-color', theme.iconDefault ?? '#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-primary', theme.primary ?? '#0078d4');
style.setProperty('--bim-measure-primary-hover', theme.primaryHover ?? '#0063b1');
style.setProperty('--bim-measure-btn-bg', theme.componentBg ?? 'rgba(255, 255, 255, 0.06)');
style.setProperty('--bim-measure-btn-hover-bg', theme.componentBgHover ?? 'rgba(255, 255, 255, 0.10)');
style.setProperty('--bim-measure-btn-active-bg', theme.componentBgActive ?? '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) 主值 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);
});
// 7) 设置面板文本
this.saveSettingsBtn.textContent = t('measure.settings.save');
this.cancelSettingsBtn.textContent = t('measure.settings.cancel');
}
/**
* 销毁组件(实现 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));
// 通知外部(如果需要)
if (this.options.onModeChange) {
this.options.onModeChange(mode);
}
// 模式切换后,结果展示也应刷新(例如某些字段显示为 --
this.renderResult();
// 切换模式会影响结果区高度(例如 distance 显示 xyz其它不显示
// 复用 onExpandedChange 来通知外部重新计算 Dialog 高度(不额外扩展回调,保持接口简单)
if (this.options.onExpandedChange) {
this.options.onExpandedChange(this.isExpanded);
}
}
/**
* 设置测量结果(由外部注入)
* @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 {
// 进入设置面板(组件内部逻辑)
this.enterSettingsView();
// 仍然保留回调(如果外部想监听)
if (this.options.onSettings) {
this.options.onSettings();
}
}
/**
* 获取当前测量配置
*/
public getConfig(): MeasureConfig {
return { ...this.config };
}
/**
* 设置测量配置(可选对外调用)
* @param partial 部分更新
* @param persist 是否写入缓存(默认 false
*/
public setConfig(partial: Partial<MeasureConfig>, persist: boolean = false): void {
const next: MeasureConfig = {
unit: partial.unit ?? this.config.unit,
precision: partial.precision ?? this.config.precision
};
this.config = next;
if (persist) {
this.saveConfigToCache(next);
}
// 配置变化会影响数值显示(单位/精度)
this.renderResult();
// 如果当前在设置面板,表单也需要同步
if (this.view === 'settings') {
this.syncSettingsFormFromConfig(next);
}
}
/**
* 展开 / 收起(可选对外调用)
* @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';
// 主视图容器(默认显示)
this.mainViewEl = document.createElement('div');
this.mainViewEl.className = 'bim-measure-main';
// 顶部:工具按钮区
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'
];
// 图标:优先使用你上传的 SVG 文件内容(已内联到 MEASURE_MODE_ICON_SVGS
// 兜底:如果某个 mode 没有配置图标,则使用圆形占位(防止页面空白)
const fallbackCircleIconSvg = `
<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 = MEASURE_MODE_ICON_SVGS[mode] || fallbackCircleIconSvg;
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);
this.mainViewEl.appendChild(toolsBox);
// 中部:结果区
const resultBox = document.createElement('div');
resultBox.className = 'bim-measure-result';
// 主结果值(随模式变化)
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;
// 主值拆分:数值(黄色)+ 单位(普通色)
// 这样可以满足:
// 1) 只让“数据”变黄,单位不变色
// 2) 没有数据时展示 `-- 单位`
this.mainNumberEl = document.createElement('span');
this.mainNumberEl.className = 'bim-measure-main-number';
this.mainUnitEl = document.createElement('span');
this.mainUnitEl.className = 'bim-measure-main-unit';
this.mainValueValueEl.appendChild(this.mainNumberEl);
this.mainValueValueEl.appendChild(document.createTextNode(' '));
this.mainValueValueEl.appendChild(this.mainUnitEl);
mainValueRow.appendChild(mainValueLabel);
mainValueRow.appendChild(mainValueValue);
resultBox.appendChild(mainValueRow);
// XYZ
const xyzBox = document.createElement('div');
xyzBox.className = 'bim-measure-xyz';
this.xyzBoxEl = xyzBox;
const makeXyzRow = (labelKey: string, valueClassName: 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 ${valueClassName}`;
valueElSetter(value);
row.appendChild(label);
row.appendChild(value);
return row;
};
xyzBox.appendChild(makeXyzRow('measure.labels.x', 'bim-measure-xyz-x', (el) => (this.xyzXEl = el)));
xyzBox.appendChild(makeXyzRow('measure.labels.y', 'bim-measure-xyz-y', (el) => (this.xyzYEl = el)));
xyzBox.appendChild(makeXyzRow('measure.labels.z', 'bim-measure-xyz-z', (el) => (this.xyzZEl = el)));
resultBox.appendChild(xyzBox);
this.mainViewEl.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);
this.mainViewEl.appendChild(footer);
// 设置视图容器(默认隐藏)
this.settingsViewEl = this.createSettingsDom();
root.appendChild(this.mainViewEl);
root.appendChild(this.settingsViewEl);
return root;
}
/**
* 创建“设置面板”DOM
*/
private createSettingsDom(): HTMLElement {
const box = document.createElement('div');
box.className = 'bim-measure-settings';
// 标题
const title = document.createElement('div');
title.className = 'bim-measure-settings-title';
title.dataset.i18nKey = 'measure.settings.title';
box.appendChild(title);
// 单位
const unitRow = document.createElement('div');
unitRow.className = 'bim-measure-settings-row';
const unitLabel = document.createElement('div');
unitLabel.className = 'label';
unitLabel.dataset.i18nKey = 'measure.settings.unit';
this.unitSelectEl = document.createElement('select');
this.unitSelectEl.className = 'bim-measure-settings-select';
this.unitSelectEl.appendChild(this.makeOption('m'));
this.unitSelectEl.appendChild(this.makeOption('cm'));
this.unitSelectEl.appendChild(this.makeOption('mm'));
this.unitSelectEl.appendChild(this.makeOption('km'));
unitRow.appendChild(unitLabel);
unitRow.appendChild(this.unitSelectEl);
box.appendChild(unitRow);
// 提示文本:你要求放在“单位”下面
const hint = document.createElement('div');
hint.className = 'bim-measure-settings-hint';
hint.dataset.i18nKey = 'measure.settings.hint';
box.appendChild(hint);
// 精度
const precisionRow = document.createElement('div');
precisionRow.className = 'bim-measure-settings-row';
const precisionLabel = document.createElement('div');
precisionLabel.className = 'label';
precisionLabel.dataset.i18nKey = 'measure.settings.precision';
this.precisionSelectEl = document.createElement('select');
this.precisionSelectEl.className = 'bim-measure-settings-select';
this.precisionSelectEl.appendChild(this.makePrecisionOption(0));
this.precisionSelectEl.appendChild(this.makePrecisionOption(1));
this.precisionSelectEl.appendChild(this.makePrecisionOption(2));
this.precisionSelectEl.appendChild(this.makePrecisionOption(3));
precisionRow.appendChild(precisionLabel);
precisionRow.appendChild(this.precisionSelectEl);
box.appendChild(precisionRow);
// 底部按钮
const actions = document.createElement('div');
actions.className = 'bim-measure-settings-actions';
this.saveSettingsBtn = document.createElement('button');
this.saveSettingsBtn.type = 'button';
this.saveSettingsBtn.className = 'bim-measure-settings-save';
this.saveSettingsBtn.addEventListener('click', () => {
this.saveSettings();
});
this.cancelSettingsBtn = document.createElement('button');
this.cancelSettingsBtn.type = 'button';
this.cancelSettingsBtn.className = 'bim-measure-settings-cancel';
this.cancelSettingsBtn.addEventListener('click', () => {
this.cancelSettings();
});
actions.appendChild(this.saveSettingsBtn);
actions.appendChild(this.cancelSettingsBtn);
box.appendChild(actions);
// 初次同步表单值
this.syncSettingsFormFromConfig(this.config);
return box;
}
private makeOption(unit: MeasureUnit): HTMLOptionElement {
const opt = document.createElement('option');
opt.value = unit;
// 选项显示内容:直接显示单位字符串
opt.textContent = unit;
return opt;
}
private makePrecisionOption(precision: MeasurePrecision): HTMLOptionElement {
const opt = document.createElement('option');
opt.value = String(precision);
// 显示0 / 0.0 / 0.00 / 0.000
opt.textContent = precision === 0 ? '0' : `0.${'0'.repeat(precision)}`;
return opt;
}
/**
* 进入设置视图:保存一份当前配置作为草稿基线
*/
private enterSettingsView(): void {
this.draftConfig = { ...this.config };
this.view = 'settings';
this.syncSettingsFormFromConfig(this.config);
this.applyViewState();
}
/**
* 保存设置:写入 config + 写缓存 + 返回主视图
*/
private saveSettings(): void {
const unit = (this.unitSelectEl.value as MeasureUnit) || this.config.unit;
const precision = (Number(this.precisionSelectEl.value) as MeasurePrecision);
const next: MeasureConfig = {
unit,
precision: this.isValidPrecision(precision) ? precision : this.config.precision
};
this.config = next;
this.saveConfigToCache(next);
this.draftConfig = null;
this.view = 'main';
this.applyViewState();
// 配置变化会影响显示
this.renderResult();
// 高度变化(设置面板 -> 主面板)也需要通知外部
if (this.options.onExpandedChange) {
this.options.onExpandedChange(this.isExpanded);
}
}
/**
* 取消设置:回滚到进入设置前的配置,并返回主视图
*/
private cancelSettings(): void {
if (this.draftConfig) {
this.config = { ...this.draftConfig };
}
this.draftConfig = null;
this.view = 'main';
this.applyViewState();
this.renderResult();
// 高度变化(设置面板 -> 主面板)也需要通知外部
if (this.options.onExpandedChange) {
this.options.onExpandedChange(this.isExpanded);
}
}
private syncSettingsFormFromConfig(config: MeasureConfig): void {
this.unitSelectEl.value = config.unit;
this.precisionSelectEl.value = String(config.precision);
}
private applyViewState(): void {
if (this.view === 'settings') {
this.mainViewEl.style.display = 'none';
// 注意CSS 里 `.bim-measure-settings { display: none; }` 是默认隐藏
// 因此这里必须显式设置为可见(否则会出现“进入设置页后什么都不显示”的问题)
this.settingsViewEl.style.display = 'block';
} else {
// 显式恢复主视图显示(避免外部样式干扰)
this.mainViewEl.style.display = 'block';
this.settingsViewEl.style.display = 'none';
}
}
/**
* 从缓存读取配置
* - 有缓存:返回解析后的配置
* - 无缓存/解析失败:返回 null
*/
private loadConfigFromCache(): MeasureConfig | null {
try {
const raw = localStorage.getItem(MeasurePanel.CONFIG_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<MeasureConfig>;
if (!parsed || typeof parsed !== 'object') return null;
const unit = parsed.unit;
const precision = parsed.precision;
if (!this.isValidUnit(unit) || !this.isValidPrecision(precision as number)) return null;
return {
unit,
precision: precision as MeasurePrecision
};
} catch (_e) {
// localStorage 可能被禁用或 JSON 格式不正确,直接忽略
return null;
}
}
/**
* 写入缓存localStorage
*/
private saveConfigToCache(config: MeasureConfig): void {
try {
localStorage.setItem(MeasurePanel.CONFIG_CACHE_KEY, JSON.stringify(config));
} catch (_e) {
// localStorage 可能被禁用:忽略即可,不影响功能
}
}
private isValidUnit(unit: any): unit is MeasureUnit {
return unit === 'm' || unit === 'cm' || unit === 'mm' || unit === 'km';
}
private isValidPrecision(precision: any): precision is MeasurePrecision {
return precision === 0 || precision === 1 || precision === 2 || precision === 3;
}
/**
* 应用“展开/收起”状态:默认只显示前 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) 根据模式决定结果区显示规则
// 你给的规则:
// - 距离:显示数值 + xyz
// - 最小距离:只显示数值
// - 角度:--°
// - 标高:--m固定 m
// - 体积:--mm³单位随设置变动即 unit³
// - 激光测距:不显示任何数值/xyz只显示“激光测距”文字
// - 坡度:--%
// - 空间体积:--mm³单位随设置变动即 unit³
// 1.1) 主行:默认显示 label + value数值/单位拆分)
// 激光测距:只显示文字,因此隐藏 label/单位
if (this.activeMode === 'laserDistance') {
this.mainValueLabelEl.style.display = 'none';
this.mainNumberEl.textContent = t(this.getModeI18nKey('laserDistance'));
this.mainUnitEl.textContent = '';
// 激光测距:你要求不使用黄色主数据
this.mainNumberEl.classList.add('is-laser-text');
} else {
this.mainValueLabelEl.style.display = '';
this.mainValueLabelEl.textContent = t(this.getModeValueLabelI18nKey(this.activeMode));
const parts = this.formatMainValueParts(this.activeMode, this.result);
this.mainNumberEl.textContent = parts.numberText;
this.mainUnitEl.textContent = parts.unitText;
// 其它模式:恢复黄色主数据
this.mainNumberEl.classList.remove('is-laser-text');
}
// 1.2) XYZ只有“距离”需要展示
if (this.activeMode === 'distance') {
this.xyzBoxEl.style.display = '';
const xyz = this.result?.xyz;
if (!xyz) {
this.xyzXEl.textContent = '--';
this.xyzYEl.textContent = '--';
this.xyzZEl.textContent = '--';
return;
}
this.xyzXEl.textContent = this.formatNumberWithPrecision(xyz.x, this.config.precision);
this.xyzYEl.textContent = this.formatNumberWithPrecision(xyz.y, this.config.precision);
this.xyzZEl.textContent = this.formatNumberWithPrecision(xyz.z, this.config.precision);
return;
}
// 非 distance隐藏 xyz
this.xyzBoxEl.style.display = 'none';
}
/**
* 获取模式名称的国际化 key
*/
private getModeI18nKey(mode: MeasureMode): string {
return `measure.modes.${mode}`;
}
/**
* 获取“主值 label”的国际化 key随模式变化
*/
private getModeValueLabelI18nKey(mode: MeasureMode): string {
return `measure.labels.value.${mode}`;
}
// 注意:旧的 formatMainValue/formatWithFixedUnit 已被 formatMainValueParts 替代,
// 以支持“数值与单位分色显示”和“无数据时仍展示单位”。
/**
* 基础数字格式化(按精度显示)
*/
private formatNumberWithPrecision(value: number, precision: MeasurePrecision): string {
// 你要求精度可选0 / 0.0 / 0.00 / 0.000,因此这里不做 trim严格按 toFixed 输出
return value.toFixed(precision);
}
// 注意:旧的 formatLengthWithConfig 已被 formatLengthParts 替代。
private convertMmToUnit(mm: number, unit: MeasureUnit): number {
switch (unit) {
case 'mm':
return mm;
case 'cm':
return mm / 10;
case 'm':
return mm / 1000;
case 'km':
return mm / 1_000_000;
default:
return mm;
}
}
private getUnitI18nKey(unit: MeasureUnit): string {
return `measure.units.${unit}`;
}
// 注意:旧的 formatElevationFixedMeters / formatVolumeWithConfig 已被 formatMainValueParts 替代。
private convertMm3ToUnit3(mm3: number, unit: MeasureUnit): number {
// 先把 mm³ -> 对应 unit³
// mm -> cm: /10因此 mm³ -> cm³: /1000
// mm -> m : /1000因此 mm³ -> m³ : /1e9
// mm -> km: /1e6因此 mm³ -> km³: /1e18
switch (unit) {
case 'mm':
return mm3;
case 'cm':
return mm3 / 1000;
case 'm':
return mm3 / 1_000_000_000;
case 'km':
return mm3 / 1_000_000_000_000_000_000;
default:
return mm3;
}
}
/**
* 主数据拆分:返回 { 数值文本, 单位文本 }
* 规则:
* - 没数据时:必须展示 `-- 单位`(而不是只展示 `--`
* - 单位随模式变化:
* - 距离/最小距离:单位随设置变动
* - 角度:°
* - 标高:固定 m
* - 体积/空间体积:单位³(随设置变动)
* - 坡度:%
*/
private formatMainValueParts(mode: MeasureMode, result: MeasureResult | null): { numberText: string; unitText: string } {
if (mode === 'laserDistance') return { numberText: t(this.getModeI18nKey('laserDistance')), unitText: '' };
// 没有数据:显示 `-- 单位`
if (!result) {
return this.getEmptyValuePartsByMode(mode);
}
switch (mode) {
case 'distance':
return this.formatLengthParts(result.distanceMm);
case 'minDistance':
return this.formatLengthParts(result.minDistanceMm);
case 'angle':
return this.formatFixedUnitParts(result.angleDeg, t('measure.units.deg'));
case 'elevation':
// 标高固定 m外部注入值约定为 mm
return this.formatFixedUnitParts(
result.elevationMm === undefined ? undefined : result.elevationMm / 1000,
t('measure.units.m')
);
case 'volume':
return this.formatVolumeParts(result.volumeM3);
case 'slope':
return this.formatFixedUnitParts(result.slopePercent, t('measure.units.percent'));
case 'spaceVolume':
return this.formatVolumeParts(result.spaceVolumeM3);
default:
return { numberText: '--', unitText: '' };
}
}
private getEmptyValuePartsByMode(mode: MeasureMode): { numberText: string; unitText: string } {
switch (mode) {
case 'distance':
case 'minDistance':
return { numberText: '--', unitText: t(this.getUnitI18nKey(this.config.unit)) };
case 'angle':
return { numberText: '--', unitText: t('measure.units.deg') };
case 'elevation':
return { numberText: '--', unitText: t('measure.units.m') };
case 'volume':
case 'spaceVolume':
return { numberText: '--', unitText: `${this.config.unit}³` };
case 'slope':
return { numberText: '--', unitText: t('measure.units.percent') };
default:
return { numberText: '--', unitText: '' };
}
}
private formatFixedUnitParts(value: number | undefined, unitText: string): { numberText: string; unitText: string } {
if (value === null || value === undefined || Number.isNaN(value)) {
return { numberText: '--', unitText };
}
return { numberText: this.formatNumberWithPrecision(value, this.config.precision), unitText };
}
private formatLengthParts(valueMm: number | undefined): { numberText: string; unitText: string } {
const unitText = t(this.getUnitI18nKey(this.config.unit));
if (valueMm === null || valueMm === undefined || Number.isNaN(valueMm)) {
return { numberText: '--', unitText };
}
const converted = this.convertMmToUnit(valueMm, this.config.unit);
return { numberText: this.formatNumberWithPrecision(converted, this.config.precision), unitText };
}
private formatVolumeParts(valueMm3: number | undefined): { numberText: string; unitText: string } {
const unitText = `${this.config.unit}³`;
if (valueMm3 === null || valueMm3 === undefined || Number.isNaN(valueMm3)) {
return { numberText: '--', unitText };
}
const converted = this.convertMm3ToUnit3(valueMm3, this.config.unit);
return { numberText: this.formatNumberWithPrecision(converted, this.config.precision), unitText };
}
}