|
|
@@ -13,8 +13,8 @@ import {
|
|
|
RangeSettingDraft,
|
|
|
withDisplayStyle
|
|
|
} from '@/utils/useSocketBus'
|
|
|
-import { useDebounceFn } from '@vueuse/core'
|
|
|
import { Setting } from '@element-plus/icons-vue'
|
|
|
+import { useDebounceFn } from '@vueuse/core'
|
|
|
import dayjs from 'dayjs'
|
|
|
import * as echarts from 'echarts'
|
|
|
|
|
|
@@ -70,6 +70,10 @@ const props = defineProps({
|
|
|
token: {
|
|
|
type: String,
|
|
|
required: true
|
|
|
+ },
|
|
|
+ active: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -81,7 +85,30 @@ const dimensions = ref<Dimensions[]>([])
|
|
|
const selectedDimension = ref<Record<string, boolean>>({})
|
|
|
const message = useMessage()
|
|
|
const { setAlarmActive } = useAlarmSound()
|
|
|
-const exceededThresholds = new Set<string>()
|
|
|
+
|
|
|
+interface ThresholdAlarm {
|
|
|
+ key: string
|
|
|
+ name: string
|
|
|
+ value: number
|
|
|
+ threshold: number
|
|
|
+ direction: 'high' | 'low'
|
|
|
+ suffix?: string
|
|
|
+}
|
|
|
+
|
|
|
+const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
|
|
|
+const activeThresholdAlarms = computed(() => Object.values(thresholdAlarms.value))
|
|
|
+
|
|
|
+function getThresholdAlarmText(alarm: ThresholdAlarm) {
|
|
|
+ const directionText = alarm.direction === 'high' ? '高于上限' : '低于下限'
|
|
|
+ const suffix = alarm.suffix || ''
|
|
|
+
|
|
|
+ return `${alarm.name}:当前 ${formatChartValue(alarm.value)}${suffix},${directionText} ${formatChartValue(alarm.threshold)}${suffix}`
|
|
|
+}
|
|
|
+
|
|
|
+function clearThresholdAlarms() {
|
|
|
+ thresholdAlarms.value = {}
|
|
|
+ setAlarmActive(false)
|
|
|
+}
|
|
|
|
|
|
const currentOtherName = ref(props.otherName)
|
|
|
|
|
|
@@ -94,7 +121,7 @@ watch(
|
|
|
}
|
|
|
)
|
|
|
|
|
|
-const { connect, destroy, isConnected, subscribe } = useMqtt()
|
|
|
+const { client, connect, destroy, isConnected, subscribe } = useMqtt()
|
|
|
|
|
|
const REALTIME_WINDOW_MS = 10 * 60 * 1000
|
|
|
const REALTIME_RIGHT_BLANK_MS = 60 * 1000
|
|
|
@@ -142,13 +169,19 @@ const handleMessageUpdate = (_topic: string, data: any) => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- if (!updatedNames.size) return
|
|
|
-
|
|
|
updateDimensionValues(valueMap)
|
|
|
updateThresholdAlarm(valueMap)
|
|
|
+
|
|
|
+ if (!updatedNames.size) return
|
|
|
+
|
|
|
scheduleRealtimeChartUpdate(Array.from(updatedNames))
|
|
|
}
|
|
|
|
|
|
+function connectRealtime() {
|
|
|
+ if (!props.active || client.value) return
|
|
|
+ connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
|
|
|
+}
|
|
|
+
|
|
|
watch(isConnected, (newVal) => {
|
|
|
if (newVal) {
|
|
|
// subscribe(`/636/${props.deviceCode}/property/post`)
|
|
|
@@ -170,8 +203,7 @@ watch(isConnected, (newVal) => {
|
|
|
subscribe(props.mqttUrl)
|
|
|
// subscribe('/636/YF649/property/post')
|
|
|
} else {
|
|
|
- exceededThresholds.clear()
|
|
|
- setAlarmActive(false)
|
|
|
+ clearThresholdAlarms()
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -211,50 +243,52 @@ let chartLoadVersion = 0
|
|
|
let realtimeUpdateFrame: number | null = null
|
|
|
const pendingRealtimeSeriesNames = new Set<string>()
|
|
|
|
|
|
+watch(
|
|
|
+ () => props.active,
|
|
|
+ async (active) => {
|
|
|
+ if (!active) {
|
|
|
+ destroy()
|
|
|
+ cancelRealtimeChartUpdate()
|
|
|
+ clearThresholdAlarms()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ await nextTick()
|
|
|
+ chart?.resize()
|
|
|
+
|
|
|
+ if (chartRealtimeMode.value) {
|
|
|
+ connectRealtime()
|
|
|
+ }
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
const TREND_AXIS_MIN = 0
|
|
|
const TREND_AXIS_MAX = 100
|
|
|
|
|
|
const resizeChart = useDebounceFn(() => {
|
|
|
chart?.resize()
|
|
|
}, 100)
|
|
|
+let chartResizeObserver: ResizeObserver | null = null
|
|
|
|
|
|
function getDimensionByName(name: string) {
|
|
|
return dimensions.value.find((item) => item.name === name)
|
|
|
}
|
|
|
|
|
|
-function getDatasetMaxValue(name: string) {
|
|
|
- const dataset = chartData.value[name] || []
|
|
|
- let maxValue = -Infinity
|
|
|
-
|
|
|
- dataset.forEach(({ value }) => {
|
|
|
- const numberValue = Number(value)
|
|
|
- if (Number.isFinite(numberValue) && numberValue > maxValue) {
|
|
|
- maxValue = numberValue
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- const currentValue = normalizeRangeValue(getDimensionByName(name)?.value)
|
|
|
- if (currentValue !== undefined && currentValue > maxValue) {
|
|
|
- maxValue = currentValue
|
|
|
- }
|
|
|
-
|
|
|
- return Number.isFinite(maxValue) ? maxValue : 0
|
|
|
-}
|
|
|
-
|
|
|
function getSeriesRange(name: string) {
|
|
|
const dimension = getDimensionByName(name)
|
|
|
- const configuredMin = normalizeRangeValue(dimension?.minValue)
|
|
|
- const configuredMax = normalizeRangeValue(dimension?.maxValue)
|
|
|
- const min = configuredMin ?? 0
|
|
|
- const max = configuredMax ?? getDatasetMaxValue(name)
|
|
|
- const normalizedMax = max > min ? max : min + 1
|
|
|
+ const configuredMin = normalizeRangeValue(dimension?.axisMin)
|
|
|
+ const configuredMax = normalizeRangeValue(dimension?.axisMax)
|
|
|
+ const configured =
|
|
|
+ configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
|
|
|
+ const min = configured ? configuredMin : 0
|
|
|
+ const max = configured ? configuredMax : 100
|
|
|
|
|
|
return {
|
|
|
min,
|
|
|
- max: normalizedMax,
|
|
|
+ max,
|
|
|
labelMin: min,
|
|
|
- labelMax: normalizedMax,
|
|
|
- configured: configuredMin !== undefined && configuredMax !== undefined
|
|
|
+ labelMax: max,
|
|
|
+ configured
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -268,7 +302,8 @@ const chartLegendItems = computed(() =>
|
|
|
bgHover: item.bgHover,
|
|
|
selected: selectedDimension.value[item.name] !== false,
|
|
|
max: formatChartValue(range.labelMax),
|
|
|
- min: formatChartValue(range.labelMin)
|
|
|
+ min: formatChartValue(range.labelMin),
|
|
|
+ alarm: thresholdAlarms.value[`${item.identifier}|${item.name}`]
|
|
|
}
|
|
|
})
|
|
|
)
|
|
|
@@ -425,6 +460,8 @@ function updateDimensionValues(valueMap: Map<string, number>) {
|
|
|
}
|
|
|
|
|
|
function updateThresholdAlarm(valueMap: Map<string, number>) {
|
|
|
+ const nextAlarms = { ...thresholdAlarms.value }
|
|
|
+
|
|
|
dimensions.value.forEach((item) => {
|
|
|
const value = valueMap.get(item.identifier)
|
|
|
if (value === undefined) return
|
|
|
@@ -432,16 +469,31 @@ function updateThresholdAlarm(valueMap: Map<string, number>) {
|
|
|
const min = normalizeRangeValue(item.minValue)
|
|
|
const max = normalizeRangeValue(item.maxValue)
|
|
|
const alarmKey = `${item.identifier}|${item.name}`
|
|
|
- const exceeded = (min !== undefined && value < min) || (max !== undefined && value > max)
|
|
|
-
|
|
|
- if (exceeded) {
|
|
|
- exceededThresholds.add(alarmKey)
|
|
|
+ if (max !== undefined && value > max) {
|
|
|
+ nextAlarms[alarmKey] = {
|
|
|
+ key: alarmKey,
|
|
|
+ name: item.name,
|
|
|
+ value,
|
|
|
+ threshold: max,
|
|
|
+ direction: 'high',
|
|
|
+ suffix: item.suffix
|
|
|
+ }
|
|
|
+ } else if (min !== undefined && value < min) {
|
|
|
+ nextAlarms[alarmKey] = {
|
|
|
+ key: alarmKey,
|
|
|
+ name: item.name,
|
|
|
+ value,
|
|
|
+ threshold: min,
|
|
|
+ direction: 'low',
|
|
|
+ suffix: item.suffix
|
|
|
+ }
|
|
|
} else {
|
|
|
- exceededThresholds.delete(alarmKey)
|
|
|
+ delete nextAlarms[alarmKey]
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- setAlarmActive(exceededThresholds.size > 0)
|
|
|
+ thresholdAlarms.value = nextAlarms
|
|
|
+ setAlarmActive(activeThresholdAlarms.value.length > 0)
|
|
|
}
|
|
|
|
|
|
function getChartAnimationOptions() {
|
|
|
@@ -553,6 +605,12 @@ function chartInit() {
|
|
|
|
|
|
window.removeEventListener('resize', resizeChart)
|
|
|
window.addEventListener('resize', resizeChart)
|
|
|
+
|
|
|
+ chartResizeObserver?.disconnect()
|
|
|
+ if (chartRef.value) {
|
|
|
+ chartResizeObserver = new ResizeObserver(resizeChart)
|
|
|
+ chartResizeObserver.observe(chartRef.value)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
function render() {
|
|
|
@@ -753,16 +811,14 @@ async function initLoadChartData(real_time: boolean = true) {
|
|
|
applySelectedDateWindow()
|
|
|
}
|
|
|
updateSeriesByNames(
|
|
|
- dimensions.value
|
|
|
- .filter((item) => selectedDimension.value[item.name])
|
|
|
- .map((item) => item.name)
|
|
|
+ dimensions.value.filter((item) => selectedDimension.value[item.name]).map((item) => item.name)
|
|
|
)
|
|
|
} finally {
|
|
|
chartLoading.value = false
|
|
|
}
|
|
|
|
|
|
if (real_time && loadVersion === chartLoadVersion) {
|
|
|
- connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
|
|
|
+ connectRealtime()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -810,6 +866,8 @@ onUnmounted(() => {
|
|
|
cancelRealtimeChartUpdate()
|
|
|
|
|
|
window.removeEventListener('resize', resizeChart)
|
|
|
+ chartResizeObserver?.disconnect()
|
|
|
+ chartResizeObserver = null
|
|
|
chart?.dispose()
|
|
|
chart = null
|
|
|
})
|
|
|
@@ -845,7 +903,9 @@ function buildRangeSettingDrafts() {
|
|
|
identifier: item.identifier,
|
|
|
color: item.color,
|
|
|
minValue: normalizeRangeValue(item.minValue),
|
|
|
- maxValue: normalizeRangeValue(item.maxValue)
|
|
|
+ maxValue: normalizeRangeValue(item.maxValue),
|
|
|
+ axisMin: normalizeRangeValue(item.axisMin),
|
|
|
+ axisMax: normalizeRangeValue(item.axisMax)
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
@@ -878,36 +938,56 @@ async function saveDeviceName() {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?: number) {
|
|
|
+async function saveRangeSetting(
|
|
|
+ item: Dimensions,
|
|
|
+ minValue?: number,
|
|
|
+ maxValue?: number,
|
|
|
+ axisMinValue?: number,
|
|
|
+ axisMaxValue?: number
|
|
|
+) {
|
|
|
const min = normalizeRangeValue(minValue)
|
|
|
const max = normalizeRangeValue(maxValue)
|
|
|
+ const axisMin = normalizeRangeValue(axisMinValue)
|
|
|
+ const axisMax = normalizeRangeValue(axisMaxValue)
|
|
|
|
|
|
- if (min === undefined && max === undefined) {
|
|
|
+ if (min === undefined && max === undefined && axisMin === undefined && axisMax === undefined) {
|
|
|
if (item.id) {
|
|
|
await IotDeviceApi.deleteMaxMin({ id: item.id })
|
|
|
}
|
|
|
|
|
|
item.minValue = undefined
|
|
|
item.maxValue = undefined
|
|
|
+ item.axisMin = undefined
|
|
|
+ item.axisMax = undefined
|
|
|
item.id = undefined
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- if (min === undefined || max === undefined) {
|
|
|
- throw new Error(`${item.name} 的最大值和最小值需要同时填写`)
|
|
|
+ if ((min === undefined) !== (max === undefined)) {
|
|
|
+ throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (min !== undefined && max !== undefined && min > max) {
|
|
|
+ throw new Error(`${item.name} 的告警下限不能大于告警上限`)
|
|
|
}
|
|
|
|
|
|
- if (min > max) {
|
|
|
- throw new Error(`${item.name} 的最小值不能大于最大值`)
|
|
|
+ if (min !== undefined && max !== undefined && min === max) {
|
|
|
+ throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
|
|
|
}
|
|
|
|
|
|
- if (min === max) {
|
|
|
- throw new Error(`${item.name} 的最大值和最小值不能相等`)
|
|
|
+ if ((axisMin === undefined) !== (axisMax === undefined)) {
|
|
|
+ throw new Error(`${item.name} 的 Y 轴上限和 Y 轴下限需要同时填写`)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (axisMin !== undefined && axisMax !== undefined && axisMin >= axisMax) {
|
|
|
+ throw new Error(`${item.name} 的 Y 轴下限必须小于 Y 轴上限`)
|
|
|
}
|
|
|
|
|
|
const body = {
|
|
|
- minValue: min,
|
|
|
- maxValue: max,
|
|
|
+ minValue: min ?? null,
|
|
|
+ maxValue: max ?? null,
|
|
|
+ axisMin: axisMin ?? null,
|
|
|
+ axisMax: axisMax ?? null,
|
|
|
deviceId: props.id,
|
|
|
propertyCode: item.identifier,
|
|
|
alarmProperty: item.name,
|
|
|
@@ -920,6 +1000,8 @@ async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?:
|
|
|
if (res.id) item.id = res.id
|
|
|
item.minValue = min
|
|
|
item.maxValue = max
|
|
|
+ item.axisMin = axisMin
|
|
|
+ item.axisMax = axisMax
|
|
|
}
|
|
|
|
|
|
async function handleSettingsSave() {
|
|
|
@@ -931,7 +1013,7 @@ async function handleSettingsSave() {
|
|
|
const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
|
|
|
if (!item) continue
|
|
|
|
|
|
- await saveRangeSetting(item, draft.minValue, draft.maxValue)
|
|
|
+ await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
|
|
|
}
|
|
|
|
|
|
updateSeriesByNames(dimensions.value.map((item) => item.name))
|
|
|
@@ -948,13 +1030,17 @@ function handleRangeDialogReset() {
|
|
|
rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
|
|
|
...item,
|
|
|
minValue: undefined,
|
|
|
- maxValue: undefined
|
|
|
+ maxValue: undefined,
|
|
|
+ axisMin: undefined,
|
|
|
+ axisMax: undefined
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
row.minValue = undefined
|
|
|
row.maxValue = undefined
|
|
|
+ row.axisMin = undefined
|
|
|
+ row.axisMax = undefined
|
|
|
}
|
|
|
</script>
|
|
|
<template>
|
|
|
@@ -963,6 +1049,10 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
<div class="flex items-center">
|
|
|
<div class="title-icon"></div>
|
|
|
<div>{{ `${props.deviceCode}-${displayDeviceName}` }}</div>
|
|
|
+ <span v-if="activeThresholdAlarms.length" class="chart-header__alarm-count">
|
|
|
+ <i class="i-material-symbols:warning-rounded"></i>
|
|
|
+ {{ activeThresholdAlarms.length }} 项超限
|
|
|
+ </span>
|
|
|
</div>
|
|
|
<div class="chart-header__actions">
|
|
|
<el-button link type="primary" :icon="Setting" @click.stop="openSettingsDialog">
|
|
|
@@ -982,8 +1072,9 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
:key="item.name"
|
|
|
type="button"
|
|
|
class="chart-legend__item"
|
|
|
- :class="{ 'is-active': item.selected }"
|
|
|
+ :class="{ 'is-active': item.selected, 'is-alarm': item.alarm }"
|
|
|
:aria-pressed="item.selected"
|
|
|
+ :title="item.alarm ? getThresholdAlarmText(item.alarm) : undefined"
|
|
|
:style="{
|
|
|
'--theme-color': item.color,
|
|
|
'--theme-bg-hover': item.bgHover
|
|
|
@@ -998,6 +1089,19 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</div>
|
|
|
|
|
|
<div class="chart-stage">
|
|
|
+ <div
|
|
|
+ v-if="activeThresholdAlarms.length"
|
|
|
+ class="chart-alarm-panel"
|
|
|
+ role="alert"
|
|
|
+ aria-live="polite">
|
|
|
+ <span
|
|
|
+ v-for="alarm in activeThresholdAlarms"
|
|
|
+ :key="alarm.key"
|
|
|
+ class="chart-alarm-panel__item">
|
|
|
+ <i class="i-material-symbols:warning-rounded"></i>
|
|
|
+ <span>{{ getThresholdAlarmText(alarm) }}</span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
<div ref="chartRef" class="chart-canvas"></div>
|
|
|
</div>
|
|
|
</main>
|
|
|
@@ -1005,7 +1109,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
<el-dialog
|
|
|
v-model="settingsDialogVisible"
|
|
|
title="设备设置"
|
|
|
- width="900px"
|
|
|
+ width="1100px"
|
|
|
class="board-settings-dialog"
|
|
|
modal-class="board-settings-overlay"
|
|
|
append-to-body
|
|
|
@@ -1021,7 +1125,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</el-form>
|
|
|
|
|
|
<div class="board-settings-section">
|
|
|
- <div class="board-settings-section__title">曲线量程设置</div>
|
|
|
+ <div class="board-settings-section__title">告警阈值与 Y 轴范围</div>
|
|
|
<ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
|
|
|
<ZmTableColumn label="曲线" min-width="180">
|
|
|
<template #default="{ row }">
|
|
|
@@ -1033,25 +1137,43 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</div>
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="最小值" width="180">
|
|
|
+ <ZmTableColumn label="告警下限" width="140">
|
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
|
v-model="row.minValue"
|
|
|
class="!w-full"
|
|
|
:controls="false"
|
|
|
- :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
|
|
|
+ placeholder="未设置" />
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="最大值" width="180">
|
|
|
+ <ZmTableColumn label="告警上限" width="140">
|
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
|
v-model="row.maxValue"
|
|
|
class="!w-full"
|
|
|
:controls="false"
|
|
|
+ placeholder="未设置" />
|
|
|
+ </template>
|
|
|
+ </ZmTableColumn>
|
|
|
+ <ZmTableColumn label="Y 轴下限" width="140">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-input-number
|
|
|
+ v-model="row.axisMin"
|
|
|
+ class="!w-full"
|
|
|
+ :controls="false"
|
|
|
+ :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
|
|
|
+ </template>
|
|
|
+ </ZmTableColumn>
|
|
|
+ <ZmTableColumn label="Y 轴上限" width="140">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-input-number
|
|
|
+ v-model="row.axisMax"
|
|
|
+ class="!w-full"
|
|
|
+ :controls="false"
|
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="当前使用范围" width="170">
|
|
|
+ <ZmTableColumn label="当前 Y 轴范围" width="150">
|
|
|
<template #default="{ row }">
|
|
|
<span class="board-settings-range-text">
|
|
|
{{ formatChartValue(getSeriesRange(row.name).labelMin) }}
|
|
|
@@ -1136,6 +1258,23 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
align-items: center;
|
|
|
}
|
|
|
|
|
|
+.chart-header__alarm-count {
|
|
|
+ display: inline-flex;
|
|
|
+ gap: 4px;
|
|
|
+ align-items: center;
|
|
|
+ height: 24px;
|
|
|
+ padding: 0 8px;
|
|
|
+ margin-left: 12px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 800;
|
|
|
+ color: #fecaca;
|
|
|
+ background: rgb(220 38 38 / 22%);
|
|
|
+ border: 1px solid rgb(248 113 113 / 70%);
|
|
|
+ border-radius: 999px;
|
|
|
+ box-shadow: 0 0 14px rgb(239 68 68 / 28%);
|
|
|
+ animation: alarm-pulse 1.4s ease-in-out infinite;
|
|
|
+}
|
|
|
+
|
|
|
.title-icon {
|
|
|
width: 4px;
|
|
|
height: 16px;
|
|
|
@@ -1228,6 +1367,39 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
color: var(--theme-color);
|
|
|
}
|
|
|
|
|
|
+.chart-legend__item.is-alarm {
|
|
|
+ color: #fecaca;
|
|
|
+ background: rgb(220 38 38 / 24%);
|
|
|
+ border-color: rgb(248 113 113 / 85%);
|
|
|
+ opacity: 1;
|
|
|
+ box-shadow:
|
|
|
+ 0 0 0 1px rgb(239 68 68 / 20%),
|
|
|
+ 0 0 18px rgb(239 68 68 / 38%);
|
|
|
+ animation: alarm-pulse 1.4s ease-in-out infinite;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-legend__item.is-alarm::after {
|
|
|
+ position: absolute;
|
|
|
+ top: 3px;
|
|
|
+ right: 3px;
|
|
|
+ width: 7px;
|
|
|
+ height: 7px;
|
|
|
+ background: #ef4444;
|
|
|
+ border: 1px solid #fecaca;
|
|
|
+ border-radius: 50%;
|
|
|
+ content: '';
|
|
|
+ box-shadow: 0 0 8px #ef4444;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-legend__item.is-alarm .chart-legend__line {
|
|
|
+ background: #ef4444;
|
|
|
+ opacity: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-legend__item.is-alarm .chart-legend__range {
|
|
|
+ color: #fecaca;
|
|
|
+}
|
|
|
+
|
|
|
.chart-legend__range {
|
|
|
font-size: 10px;
|
|
|
line-height: 1;
|
|
|
@@ -1266,6 +1438,58 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
border-radius: 8px;
|
|
|
}
|
|
|
|
|
|
+.chart-alarm-panel {
|
|
|
+ position: absolute;
|
|
|
+ top: 8px;
|
|
|
+ right: 8px;
|
|
|
+ left: 8px;
|
|
|
+ z-index: 5;
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 6px;
|
|
|
+ max-height: 76px;
|
|
|
+ padding: 7px;
|
|
|
+ overflow: auto;
|
|
|
+ background: rgb(69 10 10 / 88%);
|
|
|
+ border: 1px solid rgb(248 113 113 / 72%);
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 8px 24px rgb(0 0 0 / 36%);
|
|
|
+ backdrop-filter: blur(6px);
|
|
|
+}
|
|
|
+
|
|
|
+.chart-alarm-panel__item {
|
|
|
+ display: inline-flex;
|
|
|
+ gap: 5px;
|
|
|
+ align-items: center;
|
|
|
+ padding: 3px 7px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 1.4;
|
|
|
+ color: #fee2e2;
|
|
|
+ white-space: nowrap;
|
|
|
+ background: rgb(220 38 38 / 24%);
|
|
|
+ border: 1px solid rgb(248 113 113 / 40%);
|
|
|
+ border-radius: 999px;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes alarm-pulse {
|
|
|
+ 0%,
|
|
|
+ 100% {
|
|
|
+ filter: brightness(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ 50% {
|
|
|
+ filter: brightness(1.35);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media (prefers-reduced-motion: reduce) {
|
|
|
+ .chart-header__alarm-count,
|
|
|
+ .chart-legend__item.is-alarm {
|
|
|
+ animation: none;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
.chart-canvas {
|
|
|
width: 100%;
|
|
|
height: 100%;
|