XNSim/XNSimHtml/components/FloatingChartWindow.js

669 lines
22 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;
}
.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 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 || {})
}
}
}
};
// 确保数据集配置正确
if (chartData.datasets && chartData.datasets.length > 0) {
chartData.datasets = chartData.datasets.map(dataset => ({
...dataset,
borderWidth: 2,
pointRadius: 0,
tension: 0
}));
}
this.chartInstance = new Chart(ctx, {
type: 'line',
data: chartData,
options: finalOptions
});
}
addEventListeners() {
const header = this.shadowRoot.querySelector('.window-header');
const closeButton = this.shadowRoot.querySelector('.close-button');
const resizeHandle = this.shadowRoot.querySelector('.resize-handle');
header.addEventListener('mousedown', this.handleMouseDown);
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.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) {
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) {
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)}`;
};
// 计算所有数据点的范围
let min = Infinity;
let max = -Infinity;
data.datasets.forEach(dataset => {
if (dataset.data && dataset.data.length > 0) {
const datasetMin = Math.min(...dataset.data);
const datasetMax = Math.max(...dataset.data);
min = Math.min(min, datasetMin);
max = Math.max(max, datasetMax);
}
});
if (min !== Infinity && max !== -Infinity) {
const range = max - min;
// 如果所有数据都是0使用固定范围
if (min === 0 && max === 0) {
options.scales.y.min = -1;
options.scales.y.max = 1;
}
// 如果范围很小,使用固定比例
else if (range < 1) {
options.scales.y.min = min - 0.5;
options.scales.y.max = max + 0.5;
} else {
// 如果范围较大,使用百分比
const margin = range * 0.2; // 使用20%的边距
options.scales.y.min = min - margin;
options.scales.y.max = max + margin;
}
}
// 更新图表
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;
}
}
// 添加新方法:处理数据更新
handleDataUpdate(values, interfaceName) {
if (!this.chartInstance) return;
const chartData = this.chartInstance.data;
// 如果是第一次收到数据,创建数据集
if (chartData.datasets.length === 0) {
if (values.length > 1) {
// 创建多个数据集
chartData.datasets = values.map((_, index) => ({
label: `${interfaceName} (${index + 1})`,
data: [],
borderColor: this.getRandomColor(),
fill: false,
borderWidth: 2,
pointRadius: 0,
tension: 0
}));
} else {
// 创建单个数据集
chartData.datasets = [{
label: interfaceName,
data: [],
borderColor: this.getRandomColor(),
fill: false,
borderWidth: 2,
pointRadius: 0,
tension: 0
}];
}
}
// 添加新的数据点
chartData.labels.push(this.dataPointIndex.toString());
values.forEach((value, index) => {
if (index < chartData.datasets.length) {
chartData.datasets[index].data.push(value);
}
});
this.dataPointIndex++;
// 保持最近100个数据点
if (chartData.labels.length > 100) {
chartData.labels.shift();
chartData.datasets.forEach(dataset => {
dataset.data.shift();
});
}
// 更新图表
this.updateChartData(chartData);
}
// 添加新方法:获取随机颜色
getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
}
customElements.define('floating-chart-window', FloatingChartWindow);