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

1016 lines
41 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';
2025-12-23 11:31:16 +08:00
import type { MeasureConfig, MeasureMode, MeasurePanelOptions, MeasurePrecision, MeasureResult, MeasureUnit } from './types';
2025-12-22 18:48:38 +08:00
2025-12-24 19:02:34 +08:00
/**
* 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>`
};
2025-12-22 18:48:38 +08:00
/**
* 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;
2025-12-23 11:31:16 +08:00
/**
* /
*
* - 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
};
2025-12-22 18:48:38 +08:00
// DOM 引用(便于局部更新,减少频繁 querySelector
private toolButtons: Map<MeasureMode, HTMLButtonElement> = new Map();
private toggleBtn!: HTMLButtonElement;
private toggleTextEl!: HTMLElement;
private mainValueValueEl!: HTMLElement;
private mainValueLabelEl!: HTMLElement;
2025-12-23 11:31:16 +08:00
private mainNumberEl!: HTMLElement;
private mainUnitEl!: HTMLElement;
private xyzBoxEl!: HTMLElement;
2025-12-22 18:48:38 +08:00
private xyzXEl!: HTMLElement;
private xyzYEl!: HTMLElement;
private xyzZEl!: HTMLElement;
private clearBtn!: HTMLButtonElement;
private settingsBtn!: HTMLButtonElement;
2025-12-23 11:31:16 +08:00
// Settings DOM
private mainViewEl!: HTMLElement;
private settingsViewEl!: HTMLElement;
private unitSelectEl!: HTMLSelectElement;
private precisionSelectEl!: HTMLSelectElement;
private saveSettingsBtn!: HTMLButtonElement;
private cancelSettingsBtn!: HTMLButtonElement;
2025-12-22 18:48:38 +08:00
// 订阅清理
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;
2025-12-23 11:31:16 +08:00
// 读取配置:优先缓存,否则默认
this.config = this.loadConfigFromCache() ?? { ...MeasurePanel.DEFAULT_CONFIG };
2025-12-22 18:48:38 +08:00
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();
2025-12-23 11:31:16 +08:00
this.applyViewState();
2025-12-22 18:48:38 +08:00
this.renderResult();
// 触发初始测量模式的回调(让外部知道默认激活了哪个模式)
if (this.options.onModeChange) {
this.options.onModeChange(this.activeMode);
}
2025-12-22 18:48:38 +08:00
}
/**
* 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');
2025-12-22 18:48:38 +08:00
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');
2025-12-23 11:31:16 +08:00
// 设置面板“保存设置”按钮用主题色
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)');
2025-12-22 18:48:38 +08:00
}
/**
* 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);
2025-12-23 11:31:16 +08:00
// 4) 主值 label随模式变化
2025-12-22 18:48:38 +08:00
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);
});
2025-12-23 11:31:16 +08:00
// 7) 设置面板文本
this.saveSettingsBtn.textContent = t('measure.settings.save');
this.cancelSettingsBtn.textContent = t('measure.settings.cancel');
2025-12-22 18:48:38 +08:00
}
/**
* 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();
2025-12-23 11:31:16 +08:00
// 切换模式会影响结果区高度(例如 distance 显示 xyz其它不显示
// 复用 onExpandedChange 来通知外部重新计算 Dialog 高度(不额外扩展回调,保持接口简单)
if (this.options.onExpandedChange) {
this.options.onExpandedChange(this.isExpanded);
}
2025-12-22 18:48:38 +08:00
}
/**
*
* @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 {
2025-12-23 11:31:16 +08:00
// 进入设置面板(组件内部逻辑)
this.enterSettingsView();
// 仍然保留回调(如果外部想监听)
2025-12-22 18:48:38 +08:00
if (this.options.onSettings) {
this.options.onSettings();
}
2025-12-23 11:31:16 +08:00
}
2025-12-22 18:48:38 +08:00
2025-12-23 11:31:16 +08:00
/**
*
*/
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);
}
2025-12-22 18:48:38 +08:00
}
/**
* /
* @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';
2025-12-23 11:31:16 +08:00
// 主视图容器(默认显示)
this.mainViewEl = document.createElement('div');
this.mainViewEl.className = 'bim-measure-main';
2025-12-22 18:48:38 +08:00
// 顶部:工具按钮区
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'
];
2025-12-24 19:02:34 +08:00
// 图标:优先使用你上传的 SVG 文件内容(已内联到 MEASURE_MODE_ICON_SVGS
// 兜底:如果某个 mode 没有配置图标,则使用圆形占位(防止页面空白)
const fallbackCircleIconSvg = `
2025-12-22 18:48:38 +08:00
<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';
2025-12-24 19:02:34 +08:00
icon.innerHTML = MEASURE_MODE_ICON_SVGS[mode] || fallbackCircleIconSvg;
2025-12-22 18:48:38 +08:00
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);
2025-12-23 11:31:16 +08:00
this.mainViewEl.appendChild(toolsBox);
2025-12-22 18:48:38 +08:00
// 中部:结果区
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;
2025-12-23 11:31:16 +08:00
// 主值拆分:数值(黄色)+ 单位(普通色)
// 这样可以满足:
// 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);
2025-12-22 18:48:38 +08:00
mainValueRow.appendChild(mainValueLabel);
mainValueRow.appendChild(mainValueValue);
resultBox.appendChild(mainValueRow);
// XYZ
const xyzBox = document.createElement('div');
xyzBox.className = 'bim-measure-xyz';
2025-12-23 11:31:16 +08:00
this.xyzBoxEl = xyzBox;
2025-12-22 18:48:38 +08:00
2025-12-23 11:31:16 +08:00
const makeXyzRow = (labelKey: string, valueClassName: string, valueElSetter: (el: HTMLElement) => void) => {
2025-12-22 18:48:38 +08:00
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');
2025-12-23 11:31:16 +08:00
value.className = `value ${valueClassName}`;
2025-12-22 18:48:38 +08:00
valueElSetter(value);
row.appendChild(label);
row.appendChild(value);
return row;
};
2025-12-23 11:31:16 +08:00
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)));
2025-12-22 18:48:38 +08:00
resultBox.appendChild(xyzBox);
2025-12-23 11:31:16 +08:00
this.mainViewEl.appendChild(resultBox);
2025-12-22 18:48:38 +08:00
// 底部:删除全部 + 设置
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);
2025-12-23 11:31:16 +08:00
this.mainViewEl.appendChild(footer);
// 设置视图容器(默认隐藏)
this.settingsViewEl = this.createSettingsDom();
root.appendChild(this.mainViewEl);
root.appendChild(this.settingsViewEl);
2025-12-22 18:48:38 +08:00
return root;
}
2025-12-23 11:31:16 +08:00
/**
* 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;
}
2025-12-22 18:48:38 +08:00
/**
* / 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 {
2025-12-23 11:31:16 +08:00
// 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);
2025-12-22 18:48:38 +08:00
return;
}
2025-12-23 11:31:16 +08:00
// 非 distance隐藏 xyz
this.xyzBoxEl.style.display = 'none';
2025-12-22 18:48:38 +08:00
}
/**
* key
*/
private getModeI18nKey(mode: MeasureMode): string {
return `measure.modes.${mode}`;
}
/**
* label key
*/
private getModeValueLabelI18nKey(mode: MeasureMode): string {
return `measure.labels.value.${mode}`;
}
2025-12-23 11:31:16 +08:00
// 注意:旧的 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;
}
}
2025-12-22 18:48:38 +08:00
/**
2025-12-23 11:31:16 +08:00
* { , }
*
* - `-- 单位` `--`
* -
* - /
* - °
* - m
* - /³
* - %
2025-12-22 18:48:38 +08:00
*/
2025-12-23 11:31:16 +08:00
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);
}
2025-12-22 18:48:38 +08:00
switch (mode) {
case 'distance':
2025-12-23 11:31:16 +08:00
return this.formatLengthParts(result.distanceMm);
2025-12-22 18:48:38 +08:00
case 'minDistance':
2025-12-23 11:31:16 +08:00
return this.formatLengthParts(result.minDistanceMm);
2025-12-22 18:48:38 +08:00
case 'angle':
2025-12-23 11:31:16 +08:00
return this.formatFixedUnitParts(result.angleDeg, t('measure.units.deg'));
2025-12-22 18:48:38 +08:00
case 'elevation':
2025-12-23 11:31:16 +08:00
// 标高固定 m外部注入值约定为 mm
return this.formatFixedUnitParts(
result.elevationMm === undefined ? undefined : result.elevationMm / 1000,
t('measure.units.m')
);
2025-12-22 18:48:38 +08:00
case 'volume':
2025-12-23 11:31:16 +08:00
return this.formatVolumeParts(result.volumeM3);
2025-12-22 18:48:38 +08:00
case 'slope':
2025-12-23 11:31:16 +08:00
return this.formatFixedUnitParts(result.slopePercent, t('measure.units.percent'));
2025-12-22 18:48:38 +08:00
case 'spaceVolume':
2025-12-23 11:31:16 +08:00
return this.formatVolumeParts(result.spaceVolumeM3);
2025-12-22 18:48:38 +08:00
default:
2025-12-23 11:31:16 +08:00
return { numberText: '--', unitText: '' };
2025-12-22 18:48:38 +08:00
}
}
2025-12-23 11:31:16 +08:00
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: '' };
}
2025-12-22 18:48:38 +08:00
}
2025-12-23 11:31:16 +08:00
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 };
2025-12-22 18:48:38 +08:00
}
}