XNSim/XNSimHtml/components/FloatingChartWindow.js
2025-06-10 15:21:07 +08:00

570 lines
19 KiB
JavaScript
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.

// 确保 Chart.js 已加载
if (!window.Chart) {
const script = document.createElement('script');
script.src = '../chart.min.js';
document.head.appendChild(script);
}
class FloatingChartWindow extends HTMLElement {
// 添加静态计数器
static zIndexCounter = 1;
constructor() {
super();
this.attachShadow({ mode: 'open' });
// 初始化状态
this.position = { x: 100, y: 100 };
this.size = { width: 400, height: 300 };
this.isDragging = false;
this.dragOffset = { x: 0, y: 0 };
this.isResizing = false;
this.resizeStart = { x: 0, y: 0 };
this.isMinimized = false;
this.chartInstance = null;
this.zIndex = FloatingChartWindow.zIndexCounter++;
// 存储初始属性值
this._title = this.getAttribute('title') || '图表窗口';
this._initialPosition = this.getAttribute('initial-position') ?
JSON.parse(this.getAttribute('initial-position')) : { x: 100, y: 100 };
this._initialSize = this.getAttribute('initial-size') ?
JSON.parse(this.getAttribute('initial-size')) : { width: 400, height: 300 };
this._chartData = this.getAttribute('chart-data') ?
JSON.parse(this.getAttribute('chart-data')) : { labels: [], datasets: [] };
this._chartOptions = this.getAttribute('chart-options') ?
JSON.parse(this.getAttribute('chart-options')) : {};
// 绑定方法
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleResizeStart = this.handleResizeStart.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handleResizeEnd = this.handleResizeEnd.bind(this);
this.handleMinimize = this.handleMinimize.bind(this);
this.handleClose = this.handleClose.bind(this);
}
static get observedAttributes() {
return ['title', 'initial-position', 'initial-size', 'chart-data', 'chart-options', 'z-index'];
}
connectedCallback() {
this.render();
this.initChart();
this.addEventListeners();
// 应用初始属性值
this.updateTitle(this._title);
this.position = this._initialPosition;
this.size = this._initialSize;
this.updatePosition();
this.updateSize();
this.updateChartData(this._chartData);
this.updateChartOptions(this._chartOptions);
this.updateZIndex(this.zIndex);
// 触发 connected 事件
this.dispatchEvent(new CustomEvent('connected'));
}
disconnectedCallback() {
this.removeEventListeners();
if (this.chartInstance) {
this.chartInstance.destroy();
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
// 存储新的属性值
switch (name) {
case 'title':
this._title = newValue;
break;
case 'initial-position':
this._initialPosition = JSON.parse(newValue);
break;
case 'initial-size':
this._initialSize = JSON.parse(newValue);
break;
case 'chart-data':
this._chartData = JSON.parse(newValue);
break;
case 'chart-options':
this._chartOptions = JSON.parse(newValue);
break;
case 'z-index':
this.zIndex = parseInt(newValue);
break;
}
// 如果组件已经渲染,则更新相应的值
if (this.shadowRoot) {
switch (name) {
case 'title':
this.updateTitle(this._title);
break;
case 'initial-position':
this.position = this._initialPosition;
this.updatePosition();
break;
case 'initial-size':
this.size = this._initialSize;
this.updateSize();
break;
case 'chart-data':
this.updateChartData(this._chartData);
break;
case 'chart-options':
this.updateChartOptions(this._chartOptions);
break;
case 'z-index':
this.updateZIndex(this.zIndex);
break;
}
}
}
render() {
const style = document.createElement('style');
style.textContent = `
.floating-window {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
}
.window-header {
background: #f5f5f5;
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
user-select: none;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
}
.window-title {
font-size: 14px;
font-weight: 500;
color: #262626;
}
.window-controls {
display: flex;
gap: 8px;
}
.window-control-button {
width: 24px;
height: 24px;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #666;
border-radius: 4px;
transition: all 0.3s;
}
.window-control-button:hover {
background: rgba(0, 0, 0, 0.05);
color: #262626;
}
.window-control-button.close-button:hover {
background: #ff4d4f;
color: white;
}
.window-content {
flex: 1;
padding: 12px;
position: relative;
min-height: 0;
display: flex;
flex-direction: column;
}
.window-content.minimized {
display: none;
}
.chart-container {
flex: 1;
position: relative;
min-height: 0;
}
canvas {
width: 100% !important;
height: 100% !important;
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 10px;
height: 10px;
cursor: se-resize;
}
.resize-handle::after {
content: '';
position: absolute;
right: 2px;
bottom: 2px;
width: 6px;
height: 6px;
border-right: 2px solid #ccc;
border-bottom: 2px solid #ccc;
}
`;
const template = document.createElement('template');
template.innerHTML = `
<div class="floating-window">
<div class="window-header">
<span class="window-title">${this.getAttribute('title') || '图表窗口'}</span>
<div class="window-controls">
<button class="window-control-button minimize-button"></button>
<button class="window-control-button close-button">×</button>
</div>
</div>
<div class="window-content">
<div class="chart-container">
<canvas></canvas>
</div>
</div>
<div class="resize-handle"></div>
</div>
`;
this.shadowRoot.appendChild(style);
this.shadowRoot.appendChild(template.content.cloneNode(true));
// 更新位置和大小
this.updatePosition();
this.updateSize();
}
initChart() {
const canvas = this.shadowRoot.querySelector('canvas');
const ctx = canvas.getContext('2d');
const chartData = JSON.parse(this.getAttribute('chart-data') || '{"labels":[],"datasets":[]}');
const chartOptions = JSON.parse(this.getAttribute('chart-options') || '{}');
// 基础配置
const baseOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
elements: {
point: {
radius: 0
},
line: {
tension: 0
}
},
scales: {
y: {
beginAtZero: false,
ticks: {
callback: function(value) {
// 如果数值的绝对值大于1000或小于0.1,使用科学计数法
if (Math.abs(value) > 1000 || (Math.abs(value) < 0.1 && value !== 0)) {
return value.toExponential(1);
}
// 否则使用普通数字,保留一位小数
return value.toFixed(1);
},
maxTicksLimit: 8 // 限制刻度数量
}
},
x: {
display: true,
ticks: {
callback: function(value) {
// 如果数值大于1000使用科学计数法
if (value > 1000) {
return value.toExponential(1);
}
return value;
}
}
}
},
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
const value = context.raw;
// 始终使用普通数字格式,保留一位小数
return `${context.dataset.label}: ${value.toFixed(1)}`;
}
}
}
}
};
// 深度合并配置
const finalOptions = {
...baseOptions,
scales: {
y: {
...baseOptions.scales.y,
...(chartOptions.scales?.y || {}),
ticks: {
...baseOptions.scales.y.ticks,
...(chartOptions.scales?.y?.ticks || {})
}
},
x: {
...baseOptions.scales.x,
...(chartOptions.scales?.x || {}),
ticks: {
...baseOptions.scales.x.ticks,
...(chartOptions.scales?.x?.ticks || {})
}
}
},
plugins: {
...baseOptions.plugins,
...(chartOptions.plugins || {}),
tooltip: {
...baseOptions.plugins.tooltip,
...(chartOptions.plugins?.tooltip || {}),
callbacks: {
...baseOptions.plugins.tooltip.callbacks,
...(chartOptions.plugins?.tooltip?.callbacks || {})
}
}
}
};
this.chartInstance = new Chart(ctx, {
type: 'line',
data: chartData,
options: finalOptions
});
}
addEventListeners() {
const header = this.shadowRoot.querySelector('.window-header');
const minimizeButton = this.shadowRoot.querySelector('.minimize-button');
const closeButton = this.shadowRoot.querySelector('.close-button');
const resizeHandle = this.shadowRoot.querySelector('.resize-handle');
header.addEventListener('mousedown', this.handleMouseDown);
minimizeButton.addEventListener('click', this.handleMinimize);
closeButton.addEventListener('click', this.handleClose);
resizeHandle.addEventListener('mousedown', this.handleResizeStart);
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
}
removeEventListeners() {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
}
handleMouseDown(e) {
// 检查点击是否在标题栏或其子元素上
const header = this.shadowRoot.querySelector('.window-header');
if (header && (e.target === header || header.contains(e.target)) && !this.isMinimized) {
this.isDragging = true;
this.dragOffset = {
x: e.clientX - this.position.x,
y: e.clientY - this.position.y
};
// 点击标题栏时置顶
const window = this.shadowRoot.querySelector('.floating-window');
if (window) {
this.zIndex = FloatingChartWindow.zIndexCounter++;
window.style.zIndex = this.zIndex;
}
}
}
handleMouseMove(e) {
if (this.isDragging) {
// 限制拖动范围在当前页面区域
const minX = 300; // 左侧工具栏宽度
const minY = 100; // 顶部标签页栏高度
this.position = {
x: Math.max(minX, e.clientX - this.dragOffset.x),
y: Math.max(minY, e.clientY - this.dragOffset.y)
};
this.updatePosition();
} else if (this.isResizing && !this.isMinimized) {
const deltaX = e.clientX - this.resizeStart.x;
const deltaY = e.clientY - this.resizeStart.y;
this.size = {
width: Math.max(300, this.size.width + deltaX),
height: Math.max(200, this.size.height + deltaY)
};
this.resizeStart = {
x: e.clientX,
y: e.clientY
};
this.updateSize();
}
}
handleMouseUp() {
this.isDragging = false;
this.isResizing = false;
}
handleResizeStart(e) {
this.isResizing = true;
this.resizeStart = {
x: e.clientX,
y: e.clientY
};
}
handleResize(e) {
if (this.isResizing && !this.isMinimized) {
const deltaX = e.clientX - this.resizeStart.x;
const deltaY = e.clientY - this.resizeStart.y;
this.size = {
width: Math.max(300, this.size.width + deltaX),
height: Math.max(200, this.size.height + deltaY)
};
this.resizeStart = {
x: e.clientX,
y: e.clientY
};
this.updateSize();
// 更新图表大小
if (this.chartInstance) {
this.chartInstance.resize();
}
}
}
handleResizeEnd() {
this.isResizing = false;
}
handleMinimize() {
this.isMinimized = !this.isMinimized;
const content = this.shadowRoot.querySelector('.window-content');
const minimizeButton = this.shadowRoot.querySelector('.minimize-button');
if (this.isMinimized) {
content.classList.add('minimized');
minimizeButton.textContent = '□';
} else {
content.classList.remove('minimized');
minimizeButton.textContent = '';
}
}
handleClose() {
this.dispatchEvent(new CustomEvent('close'));
this.remove();
}
updatePosition() {
const window = this.shadowRoot.querySelector('.floating-window');
window.style.left = `${this.position.x}px`;
window.style.top = `${this.position.y}px`;
}
updateSize() {
const window = this.shadowRoot.querySelector('.floating-window');
window.style.width = `${this.size.width}px`;
window.style.height = `${this.size.height}px`;
// 强制图表重新渲染
if (this.chartInstance) {
this.chartInstance.resize();
}
}
updateTitle(title) {
const titleElement = this.shadowRoot.querySelector('.window-title');
titleElement.textContent = title;
}
updateChartData(data) {
if (this.chartInstance) {
// 更新数据
this.chartInstance.data = data;
// 强制更新配置
const options = this.chartInstance.options;
options.scales.y.ticks.callback = function(value) {
if (Math.abs(value) > 1000 || (Math.abs(value) < 0.1 && value !== 0)) {
return value.toExponential(1);
}
return value.toFixed(1);
};
options.scales.x.ticks.callback = function(value) {
if (value > 1000) {
return value.toExponential(1);
}
return value;
};
options.plugins.tooltip.callbacks.label = function(context) {
const value = context.raw;
// 始终使用普通数字格式,保留一位小数
return `${context.dataset.label}: ${value.toFixed(1)}`;
};
// 更新图表
this.chartInstance.update('none');
}
}
updateChartOptions(options) {
if (this.chartInstance) {
Object.assign(this.chartInstance.options, options);
this.chartInstance.update('none');
}
}
updateZIndex(zIndex) {
const window = this.shadowRoot.querySelector('.floating-window');
if (window) {
window.style.zIndex = zIndex;
}
}
}
customElements.define('floating-chart-window', FloatingChartWindow);