低代码平台在物联网可视化场景的落地:AI驱动的实时数据看板生成
物联网(IoT)场景的可视化需求具有高度定制化特点:不同设备、不同场景的数据指标完全不同,用户期望的展示形式也各异。传统开发模式下,每个数据看板需要前端工程师手工搭建,交付周期长且成本高。低代码平台与 AI 结合,为这一场景提供了新的解法。
一、IoT 可视化的工程痛点
物联网可视化不是简单的折线图+柱状图。实际项目中的典型需求包括:设备的实时状态拓扑图、传感器的时序数据曲线、告警事件的地理信息分布、以及基于设备树的层级钻取。
这些需求的共性是:组件类型有限但组合方式多变。以常见的 SCADA(监控与数据采集)场景为例,涉及的核心组件不超过 20 种(仪表盘、状态灯、曲线图、地图标记、设备树等),但不同项目之间的组件布局和交互逻辑差异巨大。
低代码的切入点在于:将组件标准化固化为可拖拽的物料,让配置人员通过拖拽+属性设置的方式搭建看板。AI 的切入点在于:根据设备元数据和数据特征,自动推荐合适的图表类型和布局方案,减少人工配置的试错成本。
二、系统整体架构
数据层:设备通过 MQTT 协议上报实时数据,时序数据存储在 TSDB(如 InfluxDB 或 TDengine)中,设备元数据(类型、位置、父子关系)存储在关系数据库中。
AI 推荐引擎:分析数据特征(数值范围、变化频率、维度数量),推荐合适的图表类型。例如,单值波动数据推荐仪表盘、多维度时序数据推荐多 Y 轴折线图、地理坐标数据推荐散点地图。
低代码编辑器:基于组件物料库提供拖拽搭建能力,同时接收 AI 推荐的初始布局方案,用户可以在推荐基础上微调。
三、AI 图卡推荐的核心实现
图卡推荐引擎的核心逻辑是:根据数据源的字段类型和统计特征,匹配最合适的可视化组件。以下是一个基于规则的推荐实现:
// ChartRecommender.ts — 图表类型推荐引擎
import { DataSourceMeta, ChartType, LayoutRecommendation } from './types';
/** 数据特征提取结果 */
interface DataProfile {
hasTimestamp: boolean; // 是否包含时间字段
hasLocation: boolean; // 是否包含经纬度字段
numericFields: string[]; // 数值型字段列表
categoricalFields: string[];// 分类型字段列表
cardinality: Record<string, number>; // 各字段的基数(唯一值数量)
valueRange: Record<string, { min: number; max: number }>; // 数值范围
updateFrequency: number; // 数据更新频率(次/秒)
}
/** 图表类型推荐规则 */
interface RecommendationRule {
chartType: ChartType;
condition: (profile: DataProfile) => boolean;
priority: number; // 多个规则同时匹配时的优先级
}
export class ChartRecommender {
private rules: RecommendationRule[] = [
{
chartType: 'gauge',
condition: (p) =>
p.numericFields.length === 1 &&
!p.hasTimestamp &&
p.updateFrequency > 0.5,
priority: 10,
},
{
chartType: 'line',
condition: (p) =>
p.hasTimestamp &&
p.numericFields.length >= 1 &&
p.numericFields.length <= 3,
priority: 9,
},
{
chartType: 'multiLine',
condition: (p) =>
p.hasTimestamp && p.numericFields.length > 3,
priority: 8,
},
{
chartType: 'bar',
condition: (p) =>
!p.hasTimestamp &&
p.categoricalFields.length >= 1 &&
p.numericFields.length >= 1 &&
(p.cardinality[p.categoricalFields[0]] || 0) <= 20,
priority: 7,
},
{
chartType: 'scatterMap',
condition: (p) =>
p.hasLocation &&
p.numericFields.length >= 1,
priority: 10,
},
{
chartType: 'pie',
condition: (p) =>
p.categoricalFields.length === 1 &&
p.numericFields.length === 1 &&
(p.cardinality[p.categoricalFields[0]] || 0) <= 8,
priority: 6,
},
{
chartType: 'table',
// 回退规则:当以上规则都不匹配时,使用表格
condition: () => true,
priority: 0,
},
{
chartType: 'statusIndicator',
condition: (p) =>
p.numericFields.length === 1 &&
p.valueRange[p.numericFields[0]] &&
p.valueRange[p.numericFields[0]].max <= 1 && // 0-1 值认为是状态
p.updateFrequency > 0.1,
priority: 8,
},
];
/**
* 分析数据源特征
* @param meta – 数据源的元数据描述
* @param sampleData – 样本数据(前 100 行用于特征提取)
*/
analyzeProfile(
meta: DataSourceMeta,
sampleData: Record<string, any>[]
): DataProfile {
if (sampleData.length === 0) {
throw new Error('样本数据为空,无法完成特征分析');
}
const fields = Object.keys(sampleData[0]);
const profile: DataProfile = {
hasTimestamp: false,
hasLocation: false,
numericFields: [],
categoricalFields: [],
cardinality: {},
valueRange: {},
updateFrequency: meta.updateFrequency || 0,
};
const fieldTypes = new Map<string, 'number' | 'string' | 'boolean' | 'date'>();
// 推断字段类型
for (const field of fields) {
const sampleValues = sampleData.slice(0, 50).map((r) => r[field]);
if (sampleValues.every((v) => typeof v === 'number')) {
fieldTypes.set(field, 'number');
profile.numericFields.push(field);
} else if (
sampleValues.every(
(v) => typeof v === 'string' && !isNaN(Date.parse(v))
)
) {
fieldTypes.set(field, 'date');
profile.hasTimestamp = true;
// 日期字段也可能有数值含义
profile.numericFields.push(field);
} else if (sampleValues.some((v) => typeof v === 'string')) {
fieldTypes.set(field, 'string');
profile.categoricalFields.push(field);
}
}
// 检查地理坐标字段
const latFields = fields.filter((f) =>
/^(lat|latitude|维度|y)$/i.test(f)
);
const lngFields = fields.filter((f) =>
/^(lng|longitude|lon|经度|x)$/i.test(f)
);
profile.hasLocation = latFields.length > 0 && lngFields.length > 0;
// 计算基数
for (const field of profile.categoricalFields) {
const uniqueValues = new Set(sampleData.map((r) => r[field]));
profile.cardinality[field] = uniqueValues.size;
}
// 计算数值范围
for (const field of profile.numericFields) {
const values = sampleData
.map((r) => Number(r[field]))
.filter((v) => !isNaN(v));
if (values.length > 0) {
profile.valueRange[field] = {
min: Math.min(…values),
max: Math.max(…values),
};
}
}
return profile;
}
/** 推荐图表类型 */
recommend(profile: DataProfile): ChartType[] {
const matches = this.rules
.filter((rule) => {
try {
return rule.condition(profile);
} catch {
return false;
}
})
.sort((a, b) => b.priority – a.priority)
.map((rule) => rule.chartType);
// 去重,返回前 3 个推荐
return […new Set(matches)].slice(0, 3);
}
/** 生成完整的布局推荐 */
generateLayout(
dataSources: Array<{ meta: DataSourceMeta; profile: DataProfile }>
): LayoutRecommendation {
const cards = dataSources.map((ds, index) => {
const [primaryType, …alternatives] = this.recommend(ds.profile);
return {
id: `card_${index}`,
title: ds.meta.name || `数据卡片 ${index + 1}`,
chartType: primaryType,
alternatives,
dataSourceId: ds.meta.id,
// 网格布局位置(简单按顺序排列,两列布局)
position: {
x: (index % 2) * 6, // 12 列栅格,每列占 6
y: Math.floor(index / 2) * 4,
w: 6,
h: 4,
},
};
});
return {
id: `layout_${Date.now()}`,
name: 'AI 推荐布局',
cards,
generatedAt: new Date().toISOString(),
};
}
}
四、运行时的实时数据绑定
看板的 JSON 配置需要被运行时引擎解析为可交互的页面。运行时引擎的核心职责是:解析配置 JSON → 实例化对应的 React/Vue 组件 → 建立组件与数据源的 WebSocket 连接 → 处理实时数据更新。
数据绑定采用"数据源-组件"的声明式映射:
// RuntimeBindingEngine.ts — 运行时数据绑定引擎
type DataUpdateCallback = (data: any) => void;
export class RuntimeBindingEngine {
private wsConnections = new Map<string, WebSocket>();
private dataListeners = new Map<string, Set<DataUpdateCallback>>();
private reconnectTimers = new Map<string, NodeJS.Timeout>();
/**
* 绑定组件到数据源
* @param componentId – 组件实例 ID
* @param dataSourceId – 数据源 ID
* @param onData – 数据更新回调
*/
bind(
componentId: string,
dataSourceId: string,
onData: DataUpdateCallback
): void {
const listenerKey = `${dataSourceId}:${componentId}`;
// 注册监听器
if (!this.dataListeners.has(dataSourceId)) {
this.dataListeners.set(dataSourceId, new Set());
}
this.dataListeners.get(dataSourceId)!.add(onData);
// 如果数据源连接不存在,则建立
this.ensureConnection(dataSourceId);
console.log(
`[RuntimeBinding] 组件 ${componentId} 绑定到数据源 ${dataSourceId}`
);
}
/** 确保 WebSocket 连接活跃 */
private ensureConnection(dataSourceId: string): void {
if (this.wsConnections.has(dataSourceId)) return;
const wsUrl = `wss://${process.env.IOT_WS_HOST}/stream/${dataSourceId}`;
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log(`[RuntimeBinding] 数据源 ${dataSourceId} 连接成功`);
// 清除重连定时器
const timer = this.reconnectTimers.get(dataSourceId);
if (timer) {
clearTimeout(timer);
this.reconnectTimers.delete(dataSourceId);
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// 通知所有监听该数据源的组件
const listeners = this.dataListeners.get(dataSourceId);
if (listeners) {
listeners.forEach((callback) => {
try {
callback(data);
} catch (error) {
console.error(
`[RuntimeBinding] 组件数据回调异常: ${dataSourceId}`,
error
);
}
});
}
} catch (error) {
console.error(
`[RuntimeBinding] 数据解析失败: ${dataSourceId}`,
error
);
}
};
ws.onerror = (error) => {
console.error(
`[RuntimeBinding] 数据源 ${dataSourceId} 连接错误:`,
error
);
};
ws.onclose = (event) => {
console.warn(
`[RuntimeBinding] 数据源 ${dataSourceId} 连接关闭 (code: ${event.code})`
);
this.wsConnections.delete(dataSourceId);
// 自动重连(指数退避)
if (event.code !== 1000) {
// 非正常关闭时重连
const delay = Math.min(
1000 * Math.pow(2, this.getRetryCount(dataSourceId)),
30000
);
const timer = setTimeout(() => {
this.ensureConnection(dataSourceId);
}, delay);
this.reconnectTimers.set(dataSourceId, timer);
}
};
this.wsConnections.set(dataSourceId, ws);
} catch (error) {
console.error(
`[RuntimeBinding] 创建 WebSocket 连接失败: ${dataSourceId}`,
error
);
}
}
private retryCounts = new Map<string, number>();
private getRetryCount(dataSourceId: string): number {
const count = (this.retryCounts.get(dataSourceId) || 0) + 1;
this.retryCounts.set(dataSourceId, count);
return count;
}
/** 解绑组件 */
unbind(componentId: string, dataSourceId: string): void {
const listeners = this.dataListeners.get(dataSourceId);
if (listeners) {
// 清理该组件的所有监听(简化处理)
listeners.clear();
}
// 如果该数据源已无监听者,关闭连接
if (listeners && listeners.size === 0) {
const ws = this.wsConnections.get(dataSourceId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, '无活跃监听者');
}
this.wsConnections.delete(dataSourceId);
this.dataListeners.delete(dataSourceId);
}
}
/** 关闭所有连接 */
destroy(): void {
this.wsConnections.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(1000, '引擎销毁');
}
});
this.wsConnections.clear();
this.dataListeners.clear();
this.reconnectTimers.forEach((timer) => clearTimeout(timer));
this.reconnectTimers.clear();
this.retryCounts.clear();
}
}
五、总结
低代码平台在 IoT 可视化场景的落地,关键在于两点:一是 AI 推荐引擎降低了图卡配置的学习成本,让非前端人员也能快速搭建专业的数据看板;二是运行时的实时数据绑定机制保证了看板对设备数据的响应速度。
在一个实际的智慧园区项目中,通过低代码+AI 推荐的方式,交付一个包含 15 个图卡的设备监控看板,从传统的 3 个工作日缩短到 4 小时。核心收益不在于开发效率的提升,而在于将看板搭建能力从研发团队释放到运营团队,改变了交付流程中的瓶颈点。
