fix
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { controlCaseApi, getAnalizeSeries, getControlCaseChartData, getControlCaseFieldMap } from '@/api.ts'
|
||||
import { formatApiError } from '@/api_client.ts'
|
||||
import type { ControlCase } from '@/models.ts'
|
||||
|
||||
const fieldOptions = [
|
||||
{ value: 'psi', label: 'psi' },
|
||||
{ value: 'phi', label: 'phi' },
|
||||
{ value: 'T', label: 'T' },
|
||||
{ value: 'C', label: 'C' },
|
||||
] as const
|
||||
|
||||
const parameterOptions = [
|
||||
{ value: 'Rel', label: 'Rel' },
|
||||
{ value: 'RelC', label: 'RelC' },
|
||||
{ value: 'Le', label: 'Le' },
|
||||
{ value: 'Pr', label: 'Pr' },
|
||||
{ value: 'Pe', label: 'Pe' },
|
||||
{ value: 'Ma', label: 'Ma' },
|
||||
{ value: 'Time', label: 'Time' },
|
||||
] as const
|
||||
|
||||
const selectedParameter = ref<(typeof parameterOptions)[number]['value']>('Rel')
|
||||
const filterParameter = ref('')
|
||||
const filterMin = ref('')
|
||||
const filterMax = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const caseChartRef = ref<HTMLDivElement | null>(null)
|
||||
const chartTitle = ref('')
|
||||
const seriesData = ref<{ case_id: number; x: number; omega: number; psi_max: number; case_name: string }[]>([])
|
||||
const selectedCaseId = ref<number | null>(null)
|
||||
const selectedCase = ref<ControlCase | null>(null)
|
||||
const selectedCaseLoading = ref(false)
|
||||
const selectedCaseError = ref('')
|
||||
const selectedCasePoints = ref<{ t: number; psi_m: number }[]>([])
|
||||
const selectedFieldMap = ref<{
|
||||
requested_t: number
|
||||
stage_t: number
|
||||
rows: number
|
||||
cols: number
|
||||
fields: Record<string, number[][]>
|
||||
} | null>(null)
|
||||
const selectedFieldMapLoading = ref(false)
|
||||
const selectedFieldMapError = ref('')
|
||||
const selectedField = ref<(typeof fieldOptions)[number]['value']>('psi')
|
||||
const fieldMapRef = ref<HTMLDivElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
let caseChart: echarts.ECharts | null = null
|
||||
let fieldMapChart: echarts.ECharts | null = null
|
||||
|
||||
const selectedLabel = computed(
|
||||
() => parameterOptions.find((item) => item.value === selectedParameter.value)?.label ?? selectedParameter.value,
|
||||
)
|
||||
|
||||
function buildOption() {
|
||||
return {
|
||||
title: { text: `Omega / PsiMax vs ${chartTitle.value || selectedLabel.value}` },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
const p = params.data
|
||||
const valueLabel = params.seriesName === 'Omega' ? 'Omega' : 'PsiMax'
|
||||
const value = params.seriesName === 'Omega' ? p.omega : p.psi_max
|
||||
return [
|
||||
`<strong>${p.case_name}</strong>`,
|
||||
`${selectedLabel.value}: ${p.x}`,
|
||||
`${valueLabel}: ${value}`,
|
||||
].join('<br/>')
|
||||
},
|
||||
},
|
||||
legend: { top: 28 },
|
||||
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
|
||||
xAxis: { type: 'value', name: selectedLabel.value },
|
||||
yAxis: [
|
||||
{ type: 'value', name: 'Omega', position: 'left' },
|
||||
{ type: 'value', name: 'PsiMax', position: 'right' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Omega',
|
||||
type: 'scatter',
|
||||
symbolSize: 10,
|
||||
yAxisIndex: 0,
|
||||
data: seriesData.value.map((point) => ({
|
||||
case_id: point.case_id,
|
||||
name: point.case_name,
|
||||
value: [point.x, point.omega],
|
||||
case_name: point.case_name,
|
||||
x: point.x,
|
||||
omega: point.omega,
|
||||
psi_max: point.psi_max,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'PsiMax',
|
||||
type: 'scatter',
|
||||
symbolSize: 10,
|
||||
yAxisIndex: 1,
|
||||
data: seriesData.value.map((point) => ({
|
||||
case_id: point.case_id,
|
||||
name: point.case_name,
|
||||
value: [point.x, point.psi_max],
|
||||
case_name: point.case_name,
|
||||
x: point.x,
|
||||
omega: point.omega,
|
||||
psi_max: point.psi_max,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!chart) return
|
||||
chart.setOption(buildOption(), true)
|
||||
}
|
||||
|
||||
function buildCaseOption() {
|
||||
return {
|
||||
title: { text: 'psi_m / time' },
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true },
|
||||
xAxis: { type: 'value', name: 't' },
|
||||
yAxis: { type: 'value', name: 'psi_m' },
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
data: selectedCasePoints.value.map((point) => [point.t, point.psi_m]),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function renderCaseChart() {
|
||||
if (!caseChart) return
|
||||
caseChart.setOption(buildCaseOption(), true)
|
||||
}
|
||||
|
||||
function buildFieldMapOption() {
|
||||
const payload = selectedFieldMap.value
|
||||
if (!payload) return null
|
||||
|
||||
const matrix = payload.fields[selectedField.value] ?? []
|
||||
const rows = matrix.length
|
||||
const cols = matrix[0]?.length ?? 0
|
||||
const seriesData: Array<[number, number, number]> = []
|
||||
let min = Number.POSITIVE_INFINITY
|
||||
let max = Number.NEGATIVE_INFINITY
|
||||
|
||||
matrix.forEach((row, y) => {
|
||||
row.forEach((value, x) => {
|
||||
seriesData.push([x, y, value])
|
||||
if (Number.isFinite(value)) {
|
||||
min = Math.min(min, value)
|
||||
max = Math.max(max, value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
min = 0
|
||||
max = 1
|
||||
}
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: `Field map: ${selectedField.value}`,
|
||||
subtext: `requested t=${payload.requested_t}, stage t=${payload.stage_t}`,
|
||||
},
|
||||
tooltip: {
|
||||
position: 'top',
|
||||
formatter: (params: any) => {
|
||||
const [x, y, value] = params.data as [number, number, number]
|
||||
return [`<strong>${selectedField.value}</strong>`, `x: ${x}`, `y: ${y}`, `value: ${value}`].join('<br/>')
|
||||
},
|
||||
},
|
||||
grid: { left: 56, right: 32, top: 64, bottom: 48, containLabel: true },
|
||||
xAxis: { type: 'category', name: 'x', data: Array.from({ length: cols }, (_, index) => index) },
|
||||
yAxis: { type: 'category', name: 'y', data: Array.from({ length: rows }, (_, index) => index), inverse: true },
|
||||
visualMap: {
|
||||
min,
|
||||
max,
|
||||
calculable: true,
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: 0,
|
||||
inRange: {
|
||||
color: ['#0000ff', '#00ffff', '#00ff00', '#ffff00', '#ff7f00', '#ff0000'],
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'heatmap',
|
||||
data: seriesData,
|
||||
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.35)' } },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function renderFieldMap() {
|
||||
if (!fieldMapChart || !selectedFieldMap.value) return
|
||||
const option = buildFieldMapOption()
|
||||
if (option) {
|
||||
fieldMapChart.setOption(option, true)
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value == null || value === '') return '—'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function caseObject(value: ControlCase | null) {
|
||||
return (value ?? {}) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function paramsObject(value: ControlCase | null) {
|
||||
const record = caseObject(value)
|
||||
return (record.Params ?? record.params ?? {}) as Record<string, unknown>
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await getAnalizeSeries(selectedParameter.value, {
|
||||
filter_parameter: filterParameter.value || undefined,
|
||||
filter_min: filterMin.value === '' ? undefined : Number(filterMin.value),
|
||||
filter_max: filterMax.value === '' ? undefined : Number(filterMax.value),
|
||||
})
|
||||
chartTitle.value = response.label
|
||||
seriesData.value = response.points.map((point) => ({
|
||||
case_id: point.case_id,
|
||||
x: point.x,
|
||||
omega: point.omega,
|
||||
psi_max: point.psi_max,
|
||||
case_name: point.case_name,
|
||||
}))
|
||||
renderChart()
|
||||
} catch (err) {
|
||||
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
|
||||
seriesData.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedCase(caseId: number) {
|
||||
selectedCaseId.value = caseId
|
||||
selectedCaseLoading.value = true
|
||||
selectedCaseError.value = ''
|
||||
selectedCase.value = null
|
||||
selectedCasePoints.value = []
|
||||
selectedFieldMap.value = null
|
||||
selectedFieldMapError.value = ''
|
||||
selectedFieldMapLoading.value = false
|
||||
fieldMapChart?.clear()
|
||||
|
||||
try {
|
||||
const [caseData, csvData] = await Promise.all([
|
||||
controlCaseApi.retrieve(caseId),
|
||||
getControlCaseChartData(caseId),
|
||||
])
|
||||
|
||||
selectedCase.value = caseData
|
||||
|
||||
const tIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 't')
|
||||
const psiMIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 'psi_m')
|
||||
|
||||
if (tIndex < 0 || psiMIndex < 0) {
|
||||
throw new Error('CSV does not contain t/psi_m columns')
|
||||
}
|
||||
|
||||
selectedCasePoints.value = csvData.rows
|
||||
.map((row) => ({
|
||||
t: Number(row[tIndex]),
|
||||
psi_m: Number(row[psiMIndex]),
|
||||
}))
|
||||
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_m))
|
||||
|
||||
renderCaseChart()
|
||||
} catch (err) {
|
||||
selectedCaseError.value = formatApiError(err, 'Не удалось загрузить расчетный случай.')
|
||||
} finally {
|
||||
selectedCaseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFieldMap(time: number) {
|
||||
if (!selectedCaseId.value) return
|
||||
|
||||
selectedFieldMapLoading.value = true
|
||||
selectedFieldMapError.value = ''
|
||||
|
||||
try {
|
||||
selectedFieldMap.value = await getControlCaseFieldMap(selectedCaseId.value, time)
|
||||
renderFieldMap()
|
||||
} catch (err) {
|
||||
selectedFieldMapError.value = formatApiError(err, 'Не удалось загрузить карту полей.')
|
||||
selectedFieldMap.value = null
|
||||
fieldMapChart?.clear()
|
||||
} finally {
|
||||
selectedFieldMapLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
chart?.resize()
|
||||
caseChart?.resize()
|
||||
fieldMapChart?.resize()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterParameter.value = ''
|
||||
filterMin.value = ''
|
||||
filterMax.value = ''
|
||||
void loadData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (containerRef.value) {
|
||||
chart = echarts.init(containerRef.value)
|
||||
chart.on('click', (params: any) => {
|
||||
const caseId = Number(params?.data?.case_id)
|
||||
if (Number.isFinite(caseId) && caseId > 0) {
|
||||
void loadSelectedCase(caseId)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (caseChartRef.value) {
|
||||
caseChart = echarts.init(caseChartRef.value)
|
||||
caseChart.on('click', (params: any) => {
|
||||
const t = Number(params?.data?.[0])
|
||||
if (Number.isFinite(t)) {
|
||||
void loadFieldMap(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (fieldMapRef.value) {
|
||||
fieldMapChart = echarts.init(fieldMapRef.value)
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
await loadData()
|
||||
})
|
||||
|
||||
watch(selectedParameter, loadData)
|
||||
watch(selectedField, renderFieldMap)
|
||||
watch(selectedFieldMap, renderFieldMap)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart?.dispose()
|
||||
caseChart?.dispose()
|
||||
fieldMapChart?.dispose()
|
||||
chart = null
|
||||
caseChart = null
|
||||
fieldMapChart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="d-flex flex-column gap-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Analize graph</h1>
|
||||
<p class="text-muted mb-0">Omega и PsiMax по выбранному параметру</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select v-model="selectedParameter" class="form-select">
|
||||
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading">
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-4">
|
||||
<label class="form-label">Фильтр по параметру</label>
|
||||
<select v-model="filterParameter" class="form-select">
|
||||
<option value="">Без фильтра</option>
|
||||
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label class="form-label">Min</label>
|
||||
<input v-model="filterMin" type="number" step="any" class="form-control" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label class="form-label">Max</label>
|
||||
<input v-model="filterMax" type="number" step="any" class="form-control" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-outline-secondary" type="button" @click="resetFilters" :disabled="loading">
|
||||
Сбросить фильтр
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" @click="loadData" :disabled="loading">
|
||||
Применить фильтр
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="position-relative" style="min-height: 560px;">
|
||||
<div ref="containerRef" style="height: 560px; width: 100%;"></div>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75"
|
||||
>
|
||||
<div class="text-muted">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="fw-semibold">Выбранный расчетный случай</div>
|
||||
<div v-if="selectedCaseId" class="text-muted small">ID {{ selectedCaseId }}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="selectedCaseLoading" class="text-center text-muted py-4">Загрузка...</div>
|
||||
<div v-else-if="selectedCaseError" class="alert alert-danger mb-0" role="alert">
|
||||
{{ selectedCaseError }}
|
||||
</div>
|
||||
<template v-else-if="selectedCase">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="text-muted small">Название</div>
|
||||
<div class="fw-semibold">{{ selectedCase.Name }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="text-muted small">Статус</div>
|
||||
<div class="fw-semibold">{{ selectedCase.Status }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="text-muted small">Params ID</div>
|
||||
<div class="fw-semibold">{{ selectedCase.ParamsID }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="mb-3">
|
||||
<summary class="fw-semibold">ControlCase</summary>
|
||||
<div class="table-responsive mt-2">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<tbody>
|
||||
<tr v-for="([key, value]) in Object.entries(caseObject(selectedCase))" :key="key">
|
||||
<th class="table-light" style="width: 240px">{{ key }}</th>
|
||||
<td>{{ formatValue(value) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="table-responsive mb-3">
|
||||
<div class="fw-semibold mb-2">Params</div>
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<tbody>
|
||||
<tr v-for="([key, value]) in Object.entries(paramsObject(selectedCase))" :key="key">
|
||||
<th class="table-light" style="width: 240px">{{ key }}</th>
|
||||
<td>{{ formatValue(value) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="position-relative" style="min-height: 360px;">
|
||||
<div ref="caseChartRef" style="height: 360px; width: 100%;"></div>
|
||||
<div
|
||||
v-if="!selectedCase && !selectedCaseLoading && !selectedCaseError"
|
||||
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-muted"
|
||||
>
|
||||
Нажмите на точку на верхнем графике, чтобы увидеть расчетный случай.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<button
|
||||
v-for="option in fieldOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="selectedField === option.value ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="selectedField = option.value"
|
||||
:disabled="!selectedFieldMap"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="position-relative mt-3" style="min-height: 420px;">
|
||||
<div ref="fieldMapRef" style="height: 420px; width: 100%;"></div>
|
||||
<div
|
||||
v-if="selectedFieldMapLoading"
|
||||
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75"
|
||||
>
|
||||
<div class="text-muted">Загрузка карты полей...</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="selectedFieldMapError"
|
||||
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-danger"
|
||||
>
|
||||
{{ selectedFieldMapError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!selectedFieldMap"
|
||||
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-muted"
|
||||
>
|
||||
Нажмите на точку графика `psi_m`, чтобы показать карту полей.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user