fix
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
|
import { analizeApi } from '@/api.ts'
|
||||||
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import ParamsQuickBatchCard from '@/components/params/ParamsQuickBatchCard.vue'
|
||||||
|
import { useNotify } from '@/composables/useNotify.ts'
|
||||||
|
|
||||||
type OptionItem = { value: string; label: string }
|
type OptionItem = { value: string; label: string }
|
||||||
type AnalizePoint = { case_id: number; case_name: string; x: number; omega: number; psi_max: number }
|
type AnalizePoint = { case_id: number; case_name: string; x: number; omega: number; psi_max: number }
|
||||||
@@ -36,77 +40,43 @@ type GraphSettings = {
|
|||||||
filterMax: string
|
filterMax: string
|
||||||
}
|
}
|
||||||
const parameterValues = new Set<string>(parameterOptions.map((option) => option.value))
|
const parameterValues = new Set<string>(parameterOptions.map((option) => option.value))
|
||||||
|
const notify = useNotify()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
selectedParameter: string
|
refreshToken: number
|
||||||
groupParameter: string
|
|
||||||
showTrend: boolean
|
|
||||||
filterParameter: string
|
|
||||||
filterMin: string
|
|
||||||
filterMax: string
|
|
||||||
loading: boolean
|
|
||||||
error: string
|
|
||||||
chartTitle: string
|
|
||||||
seriesData: AnalizePoint[]
|
|
||||||
groupedSeriesData: GroupedSeries[]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selectedParameter': [value: string]
|
|
||||||
'update:groupParameter': [value: string]
|
|
||||||
'update:showTrend': [value: boolean]
|
|
||||||
'update:filterParameter': [value: string]
|
|
||||||
'update:filterMin': [value: string]
|
|
||||||
'update:filterMax': [value: string]
|
|
||||||
refresh: []
|
|
||||||
resetFilters: []
|
|
||||||
selectCase: [caseId: number]
|
selectCase: [caseId: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const containerRef = ref<HTMLDivElement | null>(null)
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const selectedParameter = ref<string>('Rel')
|
||||||
|
const groupParameter = ref('')
|
||||||
|
const showTrend = ref(false)
|
||||||
|
const filterParameter = ref('')
|
||||||
|
const filterMin = ref('')
|
||||||
|
const filterMax = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const chartTitle = ref('')
|
||||||
|
const seriesData = ref<AnalizePoint[]>([])
|
||||||
|
const groupedSeriesData = ref<GroupedSeries[]>([])
|
||||||
|
const dataLoaded = ref(false)
|
||||||
|
const batchModalOpen = ref(false)
|
||||||
let chart: echarts.ECharts | null = null
|
let chart: echarts.ECharts | null = null
|
||||||
|
|
||||||
const selectedParameterModel = computed({
|
|
||||||
get: () => props.selectedParameter,
|
|
||||||
set: (value) => emit('update:selectedParameter', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const groupParameterModel = computed({
|
|
||||||
get: () => props.groupParameter,
|
|
||||||
set: (value) => emit('update:groupParameter', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const showTrendModel = computed({
|
|
||||||
get: () => props.showTrend,
|
|
||||||
set: (value) => emit('update:showTrend', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const filterParameterModel = computed({
|
|
||||||
get: () => props.filterParameter,
|
|
||||||
set: (value) => emit('update:filterParameter', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const filterMinModel = computed({
|
|
||||||
get: () => props.filterMin,
|
|
||||||
set: (value) => emit('update:filterMin', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const filterMaxModel = computed({
|
|
||||||
get: () => props.filterMax,
|
|
||||||
set: (value) => emit('update:filterMax', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedLabel = computed(
|
const selectedLabel = computed(
|
||||||
() => parameterOptions.find((item) => item.value === props.selectedParameter)?.label ?? props.selectedParameter,
|
() => parameterOptions.find((item) => item.value === selectedParameter.value)?.label ?? selectedParameter.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupLabel = computed(
|
const groupLabel = computed(
|
||||||
() => parameterOptions.find((item) => item.value === props.groupParameter)?.label ?? props.groupParameter,
|
() => parameterOptions.find((item) => item.value === groupParameter.value)?.label ?? groupParameter.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupOptions = computed(() => [
|
const groupOptions = computed(() => [
|
||||||
{ value: '', label: 'Без группировки' },
|
{ value: '', label: 'Без группировки' },
|
||||||
...parameterOptions.filter((item) => item.value !== props.selectedParameter),
|
...parameterOptions.filter((item) => item.value !== selectedParameter.value),
|
||||||
])
|
])
|
||||||
|
|
||||||
function isParameterValue(value: unknown): value is ParameterValue {
|
function isParameterValue(value: unknown): value is ParameterValue {
|
||||||
@@ -127,12 +97,12 @@ function saveGraphSettings() {
|
|||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
const settings: GraphSettings = {
|
const settings: GraphSettings = {
|
||||||
selectedParameter: props.selectedParameter as ParameterValue,
|
selectedParameter: selectedParameter.value as ParameterValue,
|
||||||
groupParameter: props.groupParameter as '' | ParameterValue,
|
groupParameter: groupParameter.value as '' | ParameterValue,
|
||||||
showTrend: props.showTrend,
|
showTrend: showTrend.value,
|
||||||
filterParameter: props.filterParameter as '' | ParameterValue,
|
filterParameter: filterParameter.value as '' | ParameterValue,
|
||||||
filterMin: props.filterMin,
|
filterMin: filterMin.value,
|
||||||
filterMax: props.filterMax,
|
filterMax: filterMax.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -146,25 +116,88 @@ function restoreGraphSettings() {
|
|||||||
const settings = storedSettings() as Partial<GraphSettings>
|
const settings = storedSettings() as Partial<GraphSettings>
|
||||||
|
|
||||||
if (isParameterValue(settings.selectedParameter)) {
|
if (isParameterValue(settings.selectedParameter)) {
|
||||||
emit('update:selectedParameter', settings.selectedParameter)
|
selectedParameter.value = settings.selectedParameter
|
||||||
}
|
}
|
||||||
if (settings.groupParameter === '' || isParameterValue(settings.groupParameter)) {
|
if (settings.groupParameter === '' || isParameterValue(settings.groupParameter)) {
|
||||||
emit('update:groupParameter', settings.groupParameter === settings.selectedParameter ? '' : settings.groupParameter)
|
groupParameter.value = settings.groupParameter === selectedParameter.value ? '' : settings.groupParameter
|
||||||
}
|
}
|
||||||
if (typeof settings.showTrend === 'boolean') {
|
if (typeof settings.showTrend === 'boolean') {
|
||||||
emit('update:showTrend', settings.showTrend)
|
showTrend.value = settings.showTrend
|
||||||
}
|
}
|
||||||
if (settings.filterParameter === '' || isParameterValue(settings.filterParameter)) {
|
if (settings.filterParameter === '' || isParameterValue(settings.filterParameter)) {
|
||||||
emit('update:filterParameter', settings.filterParameter)
|
filterParameter.value = settings.filterParameter
|
||||||
}
|
}
|
||||||
if (typeof settings.filterMin === 'string') {
|
if (typeof settings.filterMin === 'string') {
|
||||||
emit('update:filterMin', settings.filterMin)
|
filterMin.value = settings.filterMin
|
||||||
}
|
}
|
||||||
if (typeof settings.filterMax === 'string') {
|
if (typeof settings.filterMax === 'string') {
|
||||||
emit('update:filterMax', settings.filterMax)
|
filterMax.value = settings.filterMax
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await analizeApi.series(selectedParameter.value, {
|
||||||
|
group_parameter: groupParameter.value || undefined,
|
||||||
|
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
|
||||||
|
groupedSeriesData.value = (response.groups ?? []).map((group) => ({
|
||||||
|
group_value: group.group_value,
|
||||||
|
group_label: group.group_label,
|
||||||
|
points: group.points.map((point) => ({
|
||||||
|
case_id: point.case_id,
|
||||||
|
case_name: point.case_name,
|
||||||
|
x: point.x,
|
||||||
|
omega: point.omega,
|
||||||
|
psi_max: point.psi_max,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
const responsePoints = response.points ?? groupedSeriesData.value.flatMap((group) => group.points)
|
||||||
|
seriesData.value = responsePoints.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, 'Не удалось загрузить данные графика.')
|
||||||
|
notify.error(error.value)
|
||||||
|
seriesData.value = []
|
||||||
|
groupedSeriesData.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
dataLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filterParameter.value = ''
|
||||||
|
filterMin.value = ''
|
||||||
|
filterMax.value = ''
|
||||||
|
void loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchModal() {
|
||||||
|
batchModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBatchModal() {
|
||||||
|
batchModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchCreated() {
|
||||||
|
batchModalOpen.value = false
|
||||||
|
void loadData()
|
||||||
|
}
|
||||||
|
|
||||||
function psiMaxSquared(point: AnalizePoint) {
|
function psiMaxSquared(point: AnalizePoint) {
|
||||||
return point.psi_max * point.psi_max
|
return point.psi_max * point.psi_max
|
||||||
}
|
}
|
||||||
@@ -217,10 +250,10 @@ function buildPsiMaxSquaredTrend(points: AnalizePoint[], groupLabel?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOption() {
|
function buildOption() {
|
||||||
if (props.groupParameter && props.groupedSeriesData.length > 0) {
|
if (groupParameter.value && groupedSeriesData.value.length > 0) {
|
||||||
const titleGroup = groupLabel.value || props.groupParameter
|
const titleGroup = groupLabel.value || groupParameter.value
|
||||||
return {
|
return {
|
||||||
title: { text: `Omega / PsiMax^2 vs ${props.chartTitle || selectedLabel.value} grouped by ${titleGroup}` },
|
title: { text: `Omega / PsiMax^2 vs ${chartTitle.value || selectedLabel.value} grouped by ${titleGroup}` },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
@@ -239,7 +272,7 @@ function buildOption() {
|
|||||||
legend: {
|
legend: {
|
||||||
top: 0,
|
top: 0,
|
||||||
type: 'scroll',
|
type: 'scroll',
|
||||||
selected: Object.fromEntries(props.groupedSeriesData.map((group) => [`${group.group_label} · Omega`, false])),
|
selected: Object.fromEntries(groupedSeriesData.value.map((group) => [`${group.group_label} · Omega`, false])),
|
||||||
},
|
},
|
||||||
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
|
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
|
||||||
dataZoom: [
|
dataZoom: [
|
||||||
@@ -252,8 +285,8 @@ function buildOption() {
|
|||||||
{ type: 'value', name: 'Omega', position: 'left' },
|
{ type: 'value', name: 'Omega', position: 'left' },
|
||||||
{ type: 'value', name: 'PsiMax^2', position: 'right' },
|
{ type: 'value', name: 'PsiMax^2', position: 'right' },
|
||||||
],
|
],
|
||||||
series: props.groupedSeriesData.flatMap((group) => {
|
series: groupedSeriesData.value.flatMap((group) => {
|
||||||
const trend = props.showTrend ? buildPsiMaxSquaredTrend(group.points, group.group_label) : null
|
const trend = showTrend.value ? buildPsiMaxSquaredTrend(group.points, group.group_label) : null
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: `${group.group_label} · Omega`,
|
name: `${group.group_label} · Omega`,
|
||||||
@@ -291,10 +324,10 @@ function buildOption() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trend = props.showTrend ? buildPsiMaxSquaredTrend(props.seriesData) : null
|
const trend = showTrend.value ? buildPsiMaxSquaredTrend(seriesData.value) : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: { text: `Omega / PsiMax^2 vs ${props.chartTitle || selectedLabel.value}` },
|
title: { text: `Omega / PsiMax^2 vs ${chartTitle.value || selectedLabel.value}` },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
@@ -324,7 +357,7 @@ function buildOption() {
|
|||||||
symbolSize: 10,
|
symbolSize: 10,
|
||||||
yAxisIndex: 0,
|
yAxisIndex: 0,
|
||||||
encode: { x: 0, y: 1 },
|
encode: { x: 0, y: 1 },
|
||||||
data: props.seriesData.map((point) => chartPoint(point, point.omega)),
|
data: seriesData.value.map((point) => chartPoint(point, point.omega)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'PsiMax^2',
|
name: 'PsiMax^2',
|
||||||
@@ -332,7 +365,7 @@ function buildOption() {
|
|||||||
symbolSize: 10,
|
symbolSize: 10,
|
||||||
yAxisIndex: 1,
|
yAxisIndex: 1,
|
||||||
encode: { x: 0, y: 1 },
|
encode: { x: 0, y: 1 },
|
||||||
data: props.seriesData.map((point) => chartPoint(point, psiMaxSquared(point))),
|
data: seriesData.value.map((point) => chartPoint(point, psiMaxSquared(point))),
|
||||||
},
|
},
|
||||||
...(trend
|
...(trend
|
||||||
? [{
|
? [{
|
||||||
@@ -371,19 +404,22 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
renderChart()
|
|
||||||
}
|
}
|
||||||
|
void loadData()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.seriesData, props.groupedSeriesData, props.showTrend, props.chartTitle, props.selectedParameter, props.groupParameter],
|
() => [seriesData.value, groupedSeriesData.value, showTrend.value, chartTitle.value, selectedParameter.value, groupParameter.value],
|
||||||
renderChart,
|
renderChart,
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
watch(
|
watch(
|
||||||
() => [props.selectedParameter, props.groupParameter, props.showTrend, props.filterParameter, props.filterMin, props.filterMax],
|
() => [selectedParameter.value, groupParameter.value, showTrend.value, filterParameter.value, filterMin.value, filterMax.value],
|
||||||
saveGraphSettings,
|
saveGraphSettings,
|
||||||
)
|
)
|
||||||
|
watch(selectedParameter, () => dataLoaded.value && void loadData())
|
||||||
|
watch(groupParameter, () => dataLoaded.value && void loadData())
|
||||||
|
watch(() => props.refreshToken, () => dataLoaded.value && void loadData())
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
@@ -399,21 +435,24 @@ onBeforeUnmount(() => {
|
|||||||
<p class="text-muted mb-0">Omega, PsiMax² и линейный тренд по выбранному параметру</p>
|
<p class="text-muted mb-0">Omega, PsiMax² и линейный тренд по выбранному параметру</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 align-items-center">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
<select v-model="selectedParameterModel" class="form-select">
|
<select v-model="selectedParameter" class="form-select">
|
||||||
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="groupParameterModel" class="form-select">
|
<select v-model="groupParameter" class="form-select">
|
||||||
<option v-for="option in groupOptions" :key="option.value" :value="option.value">
|
<option v-for="option in groupOptions" :key="option.value" :value="option.value">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="form-check form-switch mb-0 text-nowrap">
|
<label class="form-check form-switch mb-0 text-nowrap">
|
||||||
<input v-model="showTrendModel" class="form-check-input" type="checkbox" />
|
<input v-model="showTrend" class="form-check-input" type="checkbox" />
|
||||||
<span class="form-check-label">Тренд</span>
|
<span class="form-check-label">Тренд</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="emit('refresh')" :disabled="loading">
|
<button class="btn btn-primary" type="button" @click="openBatchModal">
|
||||||
|
Создать серию
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading">
|
||||||
Обновить
|
Обновить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,7 +463,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="row g-3 align-items-end">
|
<div class="row g-3 align-items-end">
|
||||||
<div class="col-12 col-lg-4">
|
<div class="col-12 col-lg-4">
|
||||||
<label class="form-label">Фильтр по параметру</label>
|
<label class="form-label">Фильтр по параметру</label>
|
||||||
<select v-model="filterParameterModel" class="form-select">
|
<select v-model="filterParameter" class="form-select">
|
||||||
<option value="">Без фильтра</option>
|
<option value="">Без фильтра</option>
|
||||||
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
@@ -433,17 +472,17 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-lg-2">
|
<div class="col-6 col-lg-2">
|
||||||
<label class="form-label">Min</label>
|
<label class="form-label">Min</label>
|
||||||
<input v-model="filterMinModel" type="number" step="any" class="form-control" />
|
<input v-model="filterMin" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-lg-2">
|
<div class="col-6 col-lg-2">
|
||||||
<label class="form-label">Max</label>
|
<label class="form-label">Max</label>
|
||||||
<input v-model="filterMaxModel" type="number" step="any" class="form-control" />
|
<input v-model="filterMax" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-4 d-flex justify-content-end gap-2">
|
<div class="col-12 col-lg-4 d-flex justify-content-end gap-2">
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="emit('resetFilters')" :disabled="loading">
|
<button class="btn btn-outline-secondary" type="button" @click="resetFilters" :disabled="loading">
|
||||||
Сбросить фильтр
|
Сбросить фильтр
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" type="button" @click="emit('refresh')" :disabled="loading">
|
<button class="btn btn-primary" type="button" @click="loadData" :disabled="loading">
|
||||||
Применить фильтр
|
Применить фильтр
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -466,4 +505,36 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="batchModalOpen"
|
||||||
|
class="modal fade show d-block"
|
||||||
|
tabindex="-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeBatchModal"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small">Быстрый расчет</div>
|
||||||
|
<h2 class="modal-title h5 mb-0">Создание серии params</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
@click="closeBatchModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ParamsQuickBatchCard @created="handleBatchCreated" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="batchModalOpen" class="modal-backdrop fade show"></div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import * as echarts from "echarts";
|
||||||
|
import { controlCaseApi } from "@/api.ts";
|
||||||
|
import { formatApiError } from "@/api_client.ts";
|
||||||
|
import { useNotify } from "@/composables/useNotify.ts";
|
||||||
|
|
||||||
|
type FieldOption = { value: string; label: string };
|
||||||
|
type FieldMap = {
|
||||||
|
requested_t: number;
|
||||||
|
stage_t: number;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
fields: Record<string, number[][]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartSettingsStorageKey = "analize-chart-settings";
|
||||||
|
const fieldOptions = [
|
||||||
|
{ value: "psi", label: "psi" },
|
||||||
|
{ value: "phi", label: "phi" },
|
||||||
|
{ value: "T", label: "T" },
|
||||||
|
{ value: "C", label: "C" },
|
||||||
|
] as const satisfies readonly FieldOption[];
|
||||||
|
type FieldValue = (typeof fieldOptions)[number]["value"];
|
||||||
|
const fieldValues = new Set<string>(fieldOptions.map((option) => option.value));
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedCaseId: number | null;
|
||||||
|
time: number | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const fieldMapRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const selectedFieldMap = ref<FieldMap | null>(null);
|
||||||
|
const selectedFieldMapLoading = ref(false);
|
||||||
|
const selectedFieldMapError = ref("");
|
||||||
|
const selectedField = ref<string>("psi");
|
||||||
|
let fieldMapChart: echarts.ECharts | null = null;
|
||||||
|
|
||||||
|
function isFieldValue(value: unknown): value is FieldValue {
|
||||||
|
return typeof value === "string" && fieldValues.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedSettings() {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(
|
||||||
|
window.localStorage.getItem(chartSettingsStorageKey) ?? "{}",
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFieldSettings() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
chartSettingsStorageKey,
|
||||||
|
JSON.stringify({ ...storedSettings(), selectedField: selectedField.value }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors so chart controls keep working in restricted browsers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFieldSettings() {
|
||||||
|
const settings = storedSettings();
|
||||||
|
|
||||||
|
if (isFieldValue(settings.selectedField)) {
|
||||||
|
selectedField.value = settings.selectedField;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFieldMap() {
|
||||||
|
selectedFieldMapError.value = "";
|
||||||
|
selectedFieldMap.value = null;
|
||||||
|
fieldMapChart?.clear();
|
||||||
|
|
||||||
|
if (!props.selectedCaseId || props.time == null) return;
|
||||||
|
|
||||||
|
selectedFieldMapLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
selectedFieldMap.value = await controlCaseApi.fieldMap(props.selectedCaseId, props.time);
|
||||||
|
renderFieldMap();
|
||||||
|
} catch (err) {
|
||||||
|
selectedFieldMapError.value = formatApiError(err, "Не удалось загрузить карту полей.");
|
||||||
|
notify.error(selectedFieldMapError.value);
|
||||||
|
selectedFieldMap.value = null;
|
||||||
|
fieldMapChart?.clear();
|
||||||
|
} finally {
|
||||||
|
selectedFieldMapLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
dataZoom: [
|
||||||
|
{ type: "inside", xAxisIndex: 0 },
|
||||||
|
{ type: "inside", yAxisIndex: 0 },
|
||||||
|
{ type: "slider", xAxisIndex: 0, height: 18, bottom: 8 },
|
||||||
|
],
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
name: "x",
|
||||||
|
data: Array.from({ length: cols }, (_, index) => index),
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "category",
|
||||||
|
name: "y",
|
||||||
|
data: Array.from({ length: rows }, (_, index) => index),
|
||||||
|
},
|
||||||
|
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 handleResize() {
|
||||||
|
fieldMapChart?.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreFieldSettings();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (fieldMapRef.value) {
|
||||||
|
fieldMapChart = echarts.init(fieldMapRef.value);
|
||||||
|
renderFieldMap();
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
void loadFieldMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => [props.selectedCaseId, props.time], loadFieldMap);
|
||||||
|
watch(selectedField, () => {
|
||||||
|
saveFieldSettings();
|
||||||
|
renderFieldMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
fieldMapChart?.dispose();
|
||||||
|
fieldMapChart = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
@@ -1,350 +1,470 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import {
|
||||||
import * as echarts from 'echarts'
|
computed,
|
||||||
import ParamsEditCard from '@/components/params/ParamsEditCard.vue'
|
onBeforeUnmount,
|
||||||
import type { ControlCase, InitialCondition, Params } from '@/models.ts'
|
onMounted,
|
||||||
import { controlCaseStatusMeta } from '@/statuses.ts'
|
reactive,
|
||||||
|
ref,
|
||||||
|
toRaw,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
import * as echarts from "echarts";
|
||||||
|
import { analizeApi, controlCaseApi, initialConditionApi, paramsApi } from "@/api.ts";
|
||||||
|
import { formatApiError } from "@/api_client.ts";
|
||||||
|
import FieldMapCard from "@/components/analize/FieldMapCard.vue";
|
||||||
|
import ParamsEditCard from "@/components/params/ParamsEditCard.vue";
|
||||||
|
import { useNotify } from "@/composables/useNotify.ts";
|
||||||
|
import type { ControlCase, InitialCondition, Params } from "@/models.ts";
|
||||||
|
import { controlCaseStatusMeta } from "@/statuses.ts";
|
||||||
|
|
||||||
type FieldOption = { value: string; label: string }
|
type PsiViewMode = "time" | "fft";
|
||||||
type PsiViewMode = 'time' | 'fft'
|
type CasePoint = { t: number; psi_m: number };
|
||||||
type CasePoint = { t: number; psi_m: number }
|
type CasePsiLPoint = { t: number; psi_l: number };
|
||||||
type CasePsiLPoint = { t: number; psi_l: number }
|
|
||||||
type PsiSpectrum = {
|
type PsiSpectrum = {
|
||||||
time_step: number
|
time_step: number;
|
||||||
points: {
|
points: {
|
||||||
psi_m: { frequency: number; amplitude: number }[]
|
psi_m: { frequency: number; amplitude: number }[];
|
||||||
psi_l: { frequency: number; amplitude: number }[]
|
psi_l: { frequency: number; amplitude: number }[];
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
type FieldMap = {
|
|
||||||
requested_t: number
|
|
||||||
stage_t: number
|
|
||||||
rows: number
|
|
||||||
cols: number
|
|
||||||
fields: Record<string, number[][]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartSettingsStorageKey = 'analize-chart-settings'
|
const chartSettingsStorageKey = "analize-chart-settings";
|
||||||
const fieldOptions = [
|
|
||||||
{ value: 'psi', label: 'psi' },
|
|
||||||
{ value: 'phi', label: 'phi' },
|
|
||||||
{ value: 'T', label: 'T' },
|
|
||||||
{ value: 'C', label: 'C' },
|
|
||||||
] as const satisfies readonly FieldOption[]
|
|
||||||
type FieldValue = (typeof fieldOptions)[number]['value']
|
|
||||||
type CaseSettings = {
|
type CaseSettings = {
|
||||||
selectedField: FieldValue
|
psiViewMode: PsiViewMode;
|
||||||
psiViewMode: PsiViewMode
|
};
|
||||||
}
|
const notify = useNotify();
|
||||||
const fieldValues = new Set<string>(fieldOptions.map((option) => option.value))
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
selectedCaseId: number | null
|
selectedCaseId: number | null;
|
||||||
selectedCase: ControlCase | null
|
}>();
|
||||||
selectedCaseLoading: boolean
|
|
||||||
selectedCaseError: string
|
|
||||||
selectedCasePoints: CasePoint[]
|
|
||||||
selectedCasePsiLPoints: CasePsiLPoint[]
|
|
||||||
selectedCaseSpectrum: PsiSpectrum | null
|
|
||||||
selectedFieldMap: FieldMap | null
|
|
||||||
selectedFieldMapLoading: boolean
|
|
||||||
selectedFieldMapError: string
|
|
||||||
selectedField: string
|
|
||||||
psiViewMode: PsiViewMode
|
|
||||||
initialConditions: InitialCondition[]
|
|
||||||
paramsEditModalOpen: boolean
|
|
||||||
paramsSaving: boolean
|
|
||||||
paramsForm: Params
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selectedField': [value: string]
|
paramsSaved: [];
|
||||||
'update:psiViewMode': [value: PsiViewMode]
|
}>();
|
||||||
loadFieldMap: [time: number]
|
|
||||||
openParamsEditModal: []
|
|
||||||
closeParamsEditModal: []
|
|
||||||
saveParams: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const caseChartRef = ref<HTMLDivElement | null>(null)
|
const caseChartRef = ref<HTMLDivElement | null>(null);
|
||||||
const fieldMapRef = ref<HTMLDivElement | null>(null)
|
const selectedCase = ref<ControlCase | null>(null);
|
||||||
let caseChart: echarts.ECharts | null = null
|
const selectedCaseLoading = ref(false);
|
||||||
let fieldMapChart: echarts.ECharts | null = null
|
const selectedCaseError = ref("");
|
||||||
|
const statusMessage = ref("");
|
||||||
const selectedFieldModel = computed({
|
const recalculating = ref(false);
|
||||||
get: () => props.selectedField,
|
const creatingInitialCondition = ref(false);
|
||||||
set: (value) => emit('update:selectedField', value),
|
const selectedCasePoints = ref<CasePoint[]>([]);
|
||||||
})
|
const selectedCasePsiLPoints = ref<CasePsiLPoint[]>([]);
|
||||||
|
const selectedCaseSpectrum = ref<PsiSpectrum | null>(null);
|
||||||
const psiViewModeModel = computed({
|
const selectedFieldMapTime = ref<number | null>(null);
|
||||||
get: () => props.psiViewMode,
|
const psiViewMode = ref<PsiViewMode>("time");
|
||||||
set: (value) => emit('update:psiViewMode', value),
|
const initialConditions = ref<InitialCondition[]>([]);
|
||||||
})
|
const paramsEditModalOpen = ref(false);
|
||||||
|
const paramsSaving = ref(false);
|
||||||
|
const paramsForm = reactive<Params>({
|
||||||
|
id: 0,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
deleted_at: null,
|
||||||
|
rel: 0,
|
||||||
|
rel_c: 0,
|
||||||
|
le: 0,
|
||||||
|
sc: 0,
|
||||||
|
pe: 0,
|
||||||
|
ma: 0,
|
||||||
|
initial_condition_id: null,
|
||||||
|
initial_condition: null,
|
||||||
|
time: 0,
|
||||||
|
folder_path: "",
|
||||||
|
});
|
||||||
|
let caseChart: echarts.ECharts | null = null;
|
||||||
|
|
||||||
const selectedCaseParams = computed(() => {
|
const selectedCaseParams = computed(() => {
|
||||||
const record = caseObject(props.selectedCase)
|
const record = caseObject(selectedCase.value);
|
||||||
return (record.params ?? record.Params ?? null) as Params | null
|
return (record.params ?? record.Params ?? null) as Params | null;
|
||||||
})
|
});
|
||||||
|
const hiddenFields = new Set(["deleted_at", "updated_at", "created_at", "folder_path"]);
|
||||||
function isFieldValue(value: unknown): value is FieldValue {
|
|
||||||
return typeof value === 'string' && fieldValues.has(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPsiViewMode(value: unknown): value is PsiViewMode {
|
function isPsiViewMode(value: unknown): value is PsiViewMode {
|
||||||
return value === 'time' || value === 'fft'
|
return value === "time" || value === "fft";
|
||||||
}
|
}
|
||||||
|
|
||||||
function storedSettings() {
|
function storedSettings() {
|
||||||
if (typeof window === 'undefined') return {}
|
if (typeof window === "undefined") return {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(window.localStorage.getItem(chartSettingsStorageKey) ?? '{}') as Record<string, unknown>
|
return JSON.parse(
|
||||||
|
window.localStorage.getItem(chartSettingsStorageKey) ?? "{}",
|
||||||
|
) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCaseSettings() {
|
function saveCaseSettings() {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const settings: CaseSettings = {
|
const settings: CaseSettings = {
|
||||||
selectedField: props.selectedField as FieldValue,
|
psiViewMode: psiViewMode.value,
|
||||||
psiViewMode: props.psiViewMode,
|
};
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(chartSettingsStorageKey, JSON.stringify({ ...storedSettings(), ...settings }))
|
window.localStorage.setItem(
|
||||||
|
chartSettingsStorageKey,
|
||||||
|
JSON.stringify({ ...storedSettings(), ...settings }),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore storage errors so chart controls keep working in restricted browsers.
|
// Ignore storage errors so chart controls keep working in restricted browsers.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreCaseSettings() {
|
function restoreCaseSettings() {
|
||||||
const settings = storedSettings() as Partial<CaseSettings>
|
const settings = storedSettings() as Partial<CaseSettings>;
|
||||||
|
|
||||||
if (isFieldValue(settings.selectedField)) {
|
|
||||||
emit('update:selectedField', settings.selectedField)
|
|
||||||
}
|
|
||||||
if (isPsiViewMode(settings.psiViewMode)) {
|
if (isPsiViewMode(settings.psiViewMode)) {
|
||||||
emit('update:psiViewMode', settings.psiViewMode)
|
psiViewMode.value = settings.psiViewMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitialConditions() {
|
||||||
|
try {
|
||||||
|
initialConditions.value = await initialConditionApi.listAll();
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(
|
||||||
|
formatApiError(err, "Не удалось загрузить начальные условия."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openParamsEditModal() {
|
||||||
|
if (!selectedCaseParams.value) return;
|
||||||
|
Object.assign(paramsForm, { ...toRaw(selectedCaseParams.value) });
|
||||||
|
paramsEditModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeParamsEditModal() {
|
||||||
|
if (paramsSaving.value) return;
|
||||||
|
paramsEditModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveParams() {
|
||||||
|
if (!selectedCaseParams.value) return;
|
||||||
|
|
||||||
|
paramsSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await paramsApi.update(selectedCaseParams.value.id, {
|
||||||
|
rel: Number(paramsForm.rel),
|
||||||
|
rel_c: Number(paramsForm.rel_c),
|
||||||
|
le: Number(paramsForm.le),
|
||||||
|
sc: Number(paramsForm.sc),
|
||||||
|
pe: Number(paramsForm.pe),
|
||||||
|
ma: Number(paramsForm.ma),
|
||||||
|
initial_condition_id: paramsForm.initial_condition_id,
|
||||||
|
time: Number(paramsForm.time),
|
||||||
|
folder_path: paramsForm.folder_path,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedCase.value) {
|
||||||
|
selectedCase.value = {
|
||||||
|
...selectedCase.value,
|
||||||
|
params: updated,
|
||||||
|
Params: updated,
|
||||||
|
} as ControlCase;
|
||||||
|
}
|
||||||
|
paramsEditModalOpen.value = false;
|
||||||
|
emit("paramsSaved");
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(formatApiError(err, "Не удалось сохранить params."));
|
||||||
|
} finally {
|
||||||
|
paramsSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalculateAnalysis() {
|
||||||
|
if (!props.selectedCaseId) return;
|
||||||
|
|
||||||
|
recalculating.value = true;
|
||||||
|
selectedCaseError.value = "";
|
||||||
|
statusMessage.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await analizeApi.recalculateControlCase(props.selectedCaseId);
|
||||||
|
statusMessage.value = "Анализ пересчитан.";
|
||||||
|
emit("paramsSaved");
|
||||||
|
} catch (err) {
|
||||||
|
selectedCaseError.value = formatApiError(err, "Не удалось пересчитать анализ.");
|
||||||
|
notify.error(selectedCaseError.value);
|
||||||
|
} finally {
|
||||||
|
recalculating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInitialCondition() {
|
||||||
|
if (!props.selectedCaseId) return;
|
||||||
|
|
||||||
|
creatingInitialCondition.value = true;
|
||||||
|
selectedCaseError.value = "";
|
||||||
|
statusMessage.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controlCaseApi.createInitialCondition(props.selectedCaseId);
|
||||||
|
statusMessage.value = `Начальные условия созданы: ${response.initial_condition.file_path}`;
|
||||||
|
await loadInitialConditions();
|
||||||
|
} catch (err) {
|
||||||
|
selectedCaseError.value = formatApiError(err, "Не удалось создать начальные условия.");
|
||||||
|
notify.error(selectedCaseError.value);
|
||||||
|
} finally {
|
||||||
|
creatingInitialCondition.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedCase(caseId: number | null) {
|
||||||
|
selectedCaseLoading.value = !!caseId;
|
||||||
|
selectedCaseError.value = "";
|
||||||
|
statusMessage.value = "";
|
||||||
|
selectedCase.value = null;
|
||||||
|
selectedCasePoints.value = [];
|
||||||
|
selectedCasePsiLPoints.value = [];
|
||||||
|
selectedCaseSpectrum.value = null;
|
||||||
|
selectedFieldMapTime.value = null;
|
||||||
|
|
||||||
|
if (!caseId) {
|
||||||
|
selectedCaseLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [caseData, csvData, spectrumData] = await Promise.all([
|
||||||
|
controlCaseApi.retrieve(caseId),
|
||||||
|
controlCaseApi.chartData(caseId),
|
||||||
|
controlCaseApi.psiSpectrum(caseId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
selectedCase.value = caseData;
|
||||||
|
|
||||||
|
const tIndex = csvData.columns.findIndex(
|
||||||
|
(column) => column.toLowerCase() === "t",
|
||||||
|
);
|
||||||
|
const psiMIndex = csvData.columns.findIndex(
|
||||||
|
(column) => column.toLowerCase() === "psi_m",
|
||||||
|
);
|
||||||
|
const psiLIndex = csvData.columns.findIndex(
|
||||||
|
(column) => column.toLowerCase() === "psi_l",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tIndex < 0 || psiMIndex < 0 || psiLIndex < 0) {
|
||||||
|
throw new Error("CSV does not contain t/psi_m/psi_l 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),
|
||||||
|
);
|
||||||
|
|
||||||
|
selectedCasePsiLPoints.value = csvData.rows
|
||||||
|
.map((row) => ({
|
||||||
|
t: Number(row[tIndex]),
|
||||||
|
psi_l: Number(row[psiLIndex]),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(point) => Number.isFinite(point.t) && Number.isFinite(point.psi_l),
|
||||||
|
);
|
||||||
|
|
||||||
|
selectedCaseSpectrum.value = spectrumData;
|
||||||
|
renderCaseChart();
|
||||||
|
} catch (err) {
|
||||||
|
selectedCaseError.value = formatApiError(
|
||||||
|
err,
|
||||||
|
"Не удалось загрузить расчетный случай.",
|
||||||
|
);
|
||||||
|
notify.error(selectedCaseError.value);
|
||||||
|
} finally {
|
||||||
|
selectedCaseLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: unknown) {
|
function formatValue(value: unknown) {
|
||||||
if (value == null || value === '') return '—'
|
if (value == null || value === "") return "—";
|
||||||
if (typeof value === 'object') return JSON.stringify(value)
|
if (typeof value === "object") return JSON.stringify(value);
|
||||||
return String(value)
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function caseObject(value: ControlCase | null) {
|
function caseObject(value: ControlCase | null) {
|
||||||
return (value ?? {}) as Record<string, unknown>
|
return (value ?? {}) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function paramsObject(value: ControlCase | null) {
|
function paramsObject(value: ControlCase | null) {
|
||||||
const record = caseObject(value)
|
const record = caseObject(value);
|
||||||
return (record.params ?? record.Params ?? {}) as Record<string, unknown>
|
return (record.params ?? record.Params ?? {}) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleEntries(value: Record<string, unknown>) {
|
||||||
|
return Object.entries(value).filter(([key]) => !hiddenFields.has(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCaseOption() {
|
function buildCaseOption() {
|
||||||
if (props.psiViewMode === 'fft') {
|
if (psiViewMode.value === "fft") {
|
||||||
return {
|
return {
|
||||||
title: { text: 'FFT spectrum of psi' },
|
title: { text: "FFT spectrum of psi" },
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: "axis" },
|
||||||
legend: { top: 0 },
|
legend: { top: 0 },
|
||||||
grid: { left: 56, right: 24, top: 48, bottom: 56, containLabel: true },
|
grid: { left: 56, right: 24, top: 48, bottom: 56, containLabel: true },
|
||||||
dataZoom: [
|
dataZoom: [
|
||||||
{ type: 'inside', xAxisIndex: 0 },
|
{ type: "inside", xAxisIndex: 0 },
|
||||||
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
{ type: "slider", xAxisIndex: 0, height: 18, bottom: 8 },
|
||||||
],
|
],
|
||||||
xAxis: { type: 'value', name: 'f' },
|
xAxis: { type: "value", name: "f" },
|
||||||
yAxis: { type: 'value', name: 'Amplitude' },
|
yAxis: { type: "value", name: "Amplitude" },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'psi_m',
|
name: "psi_m",
|
||||||
type: 'line',
|
type: "line",
|
||||||
smooth: true,
|
smooth: true,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
data: props.selectedCaseSpectrum?.points.psi_m.map((point) => [point.frequency, point.amplitude]) ?? [],
|
data:
|
||||||
|
selectedCaseSpectrum.value?.points.psi_m.map((point) => [
|
||||||
|
point.frequency,
|
||||||
|
point.amplitude,
|
||||||
|
]) ?? [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'psi_l',
|
name: "psi_l",
|
||||||
type: 'line',
|
type: "line",
|
||||||
smooth: true,
|
smooth: true,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
data: props.selectedCaseSpectrum?.points.psi_l.map((point) => [point.frequency, point.amplitude]) ?? [],
|
data:
|
||||||
|
selectedCaseSpectrum.value?.points.psi_l.map((point) => [
|
||||||
|
point.frequency,
|
||||||
|
point.amplitude,
|
||||||
|
]) ?? [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: { text: 'psi_m / psi_l / time' },
|
title: { text: "psi_m / psi_l / time" },
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: "axis" },
|
||||||
legend: { top: 0 },
|
legend: { top: 0 },
|
||||||
grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true },
|
grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true },
|
||||||
dataZoom: [
|
dataZoom: [
|
||||||
{ type: 'inside', xAxisIndex: 0 },
|
{ type: "inside", xAxisIndex: 0 },
|
||||||
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
{ type: "slider", xAxisIndex: 0, height: 18, bottom: 8 },
|
||||||
],
|
],
|
||||||
xAxis: { type: 'value', name: 't' },
|
xAxis: { type: "value", name: "t" },
|
||||||
yAxis: { type: 'value', name: 'psi' },
|
yAxis: { type: "value", name: "psi" },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'psi_m',
|
name: "psi_m",
|
||||||
type: 'line',
|
type: "line",
|
||||||
smooth: true,
|
smooth: true,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
data: props.selectedCasePoints.map((point) => [point.t, point.psi_m]),
|
data: selectedCasePoints.value.map((point) => [point.t, point.psi_m]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'psi_l',
|
name: "psi_l",
|
||||||
type: 'line',
|
type: "line",
|
||||||
smooth: true,
|
smooth: true,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
data: props.selectedCasePsiLPoints.map((point) => [point.t, point.psi_l]),
|
data: selectedCasePsiLPoints.value.map((point) => [
|
||||||
|
point.t,
|
||||||
|
point.psi_l,
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCaseChart() {
|
function renderCaseChart() {
|
||||||
if (!caseChart) return
|
if (!caseChart) return;
|
||||||
caseChart.setOption(buildCaseOption(), true)
|
caseChart.setOption(buildCaseOption(), true);
|
||||||
}
|
|
||||||
|
|
||||||
function buildFieldMapOption() {
|
|
||||||
const payload = props.selectedFieldMap
|
|
||||||
if (!payload) return null
|
|
||||||
|
|
||||||
const matrix = payload.fields[props.selectedField] ?? []
|
|
||||||
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: ${props.selectedField}`,
|
|
||||||
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>${props.selectedField}</strong>`, `x: ${x}`, `y: ${y}`, `value: ${value}`].join('<br/>')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: { left: 56, right: 32, top: 64, bottom: 48, containLabel: true },
|
|
||||||
dataZoom: [
|
|
||||||
{ type: 'inside', xAxisIndex: 0 },
|
|
||||||
{ type: 'inside', yAxisIndex: 0 },
|
|
||||||
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
|
||||||
],
|
|
||||||
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 || !props.selectedFieldMap) return
|
|
||||||
const option = buildFieldMapOption()
|
|
||||||
if (option) {
|
|
||||||
fieldMapChart.setOption(option, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFieldMap() {
|
|
||||||
if (!props.selectedFieldMap) {
|
|
||||||
fieldMapChart?.clear()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
caseChart?.resize()
|
caseChart?.resize();
|
||||||
fieldMapChart?.resize()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreCaseSettings()
|
restoreCaseSettings();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (caseChartRef.value) {
|
if (caseChartRef.value) {
|
||||||
caseChart = echarts.init(caseChartRef.value)
|
caseChart = echarts.init(caseChartRef.value);
|
||||||
caseChart.on('click', (params: any) => {
|
caseChart.on("click", (params: any) => {
|
||||||
const t = Number(params?.data?.[0])
|
const t = Number(params?.data?.[0]);
|
||||||
if (Number.isFinite(t)) {
|
if (Number.isFinite(t)) {
|
||||||
emit('loadFieldMap', t)
|
selectedFieldMapTime.value = t;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
renderCaseChart()
|
renderCaseChart();
|
||||||
}
|
}
|
||||||
if (fieldMapRef.value) {
|
window.addEventListener("resize", handleResize);
|
||||||
fieldMapChart = echarts.init(fieldMapRef.value)
|
void loadInitialConditions();
|
||||||
renderFieldMap()
|
void loadSelectedCase(props.selectedCaseId);
|
||||||
}
|
});
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.selectedCasePoints, props.selectedCasePsiLPoints, props.selectedCaseSpectrum, props.psiViewMode],
|
() => [
|
||||||
|
selectedCasePoints.value,
|
||||||
|
selectedCasePsiLPoints.value,
|
||||||
|
selectedCaseSpectrum.value,
|
||||||
|
psiViewMode.value,
|
||||||
|
],
|
||||||
renderCaseChart,
|
renderCaseChart,
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
);
|
||||||
watch(() => [props.selectedField, props.selectedFieldMap], renderFieldMap, { deep: true })
|
watch(psiViewMode, saveCaseSettings);
|
||||||
watch(() => props.selectedFieldMap, clearFieldMap)
|
watch(() => props.selectedCaseId, loadSelectedCase);
|
||||||
watch(() => [props.selectedField, props.psiViewMode], saveCaseSettings)
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener("resize", handleResize);
|
||||||
caseChart?.dispose()
|
caseChart?.dispose();
|
||||||
fieldMapChart?.dispose()
|
caseChart = null;
|
||||||
caseChart = null
|
});
|
||||||
fieldMapChart = null
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card shadow-sm">
|
<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="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2"
|
||||||
|
>
|
||||||
<div class="fw-semibold">Выбранный расчетный случай</div>
|
<div class="fw-semibold">Выбранный расчетный случай</div>
|
||||||
<div v-if="selectedCaseId" class="text-muted small">ID {{ selectedCaseId }}</div>
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<div v-if="selectedCaseId" class="text-muted small">
|
||||||
|
ID {{ selectedCaseId }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedCase"
|
||||||
|
class="btn btn-sm btn-outline-success"
|
||||||
|
type="button"
|
||||||
|
@click="recalculateAnalysis"
|
||||||
|
:disabled="recalculating"
|
||||||
|
>
|
||||||
|
{{ recalculating ? "Пересчет..." : "Пересчитать анализ" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedCase"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
@click="createInitialCondition"
|
||||||
|
:disabled="creatingInitialCondition"
|
||||||
|
>
|
||||||
|
{{ creatingInitialCondition ? "Создание..." : "Взять за начальные условия" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div v-if="selectedCaseLoading" class="text-center text-muted py-4">Загрузка...</div>
|
<div v-if="statusMessage" class="alert alert-success mb-3" role="alert">
|
||||||
<div v-else-if="selectedCaseError" class="alert alert-danger mb-0" role="alert">
|
{{ statusMessage }}
|
||||||
|
</div>
|
||||||
|
<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 }}
|
{{ selectedCaseError }}
|
||||||
</div>
|
</div>
|
||||||
<template v-else-if="selectedCase">
|
<template v-else-if="selectedCase">
|
||||||
@@ -355,7 +475,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-4">
|
<div class="col-12 col-lg-4">
|
||||||
<div class="text-muted small">Статус</div>
|
<div class="text-muted small">Статус</div>
|
||||||
<span class="badge" :class="controlCaseStatusMeta(selectedCase.status).className">
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="controlCaseStatusMeta(selectedCase.status).className"
|
||||||
|
>
|
||||||
{{ controlCaseStatusMeta(selectedCase.status).label }}
|
{{ controlCaseStatusMeta(selectedCase.status).label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,7 +493,12 @@ onBeforeUnmount(() => {
|
|||||||
<div class="table-responsive mt-2">
|
<div class="table-responsive mt-2">
|
||||||
<table class="table table-sm align-middle mb-0">
|
<table class="table table-sm align-middle mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="([key, value]) in Object.entries(caseObject(selectedCase))" :key="key">
|
<tr
|
||||||
|
v-for="[key, value] in visibleEntries(
|
||||||
|
caseObject(selectedCase),
|
||||||
|
)"
|
||||||
|
:key="key"
|
||||||
|
>
|
||||||
<th class="table-light" style="width: 240px">{{ key }}</th>
|
<th class="table-light" style="width: 240px">{{ key }}</th>
|
||||||
<td>{{ formatValue(value) }}</td>
|
<td>{{ formatValue(value) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -380,22 +508,42 @@ onBeforeUnmount(() => {
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="table-responsive mb-3">
|
<div class="table-responsive mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
|
<div
|
||||||
|
class="d-flex justify-content-between align-items-center gap-2 mb-2"
|
||||||
|
>
|
||||||
<div class="fw-semibold">Params</div>
|
<div class="fw-semibold">Params</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-outline-primary"
|
class="btn btn-sm btn-outline-primary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!selectedCaseParams"
|
:disabled="!selectedCaseParams"
|
||||||
@click="emit('openParamsEditModal')"
|
@click="openParamsEditModal"
|
||||||
>
|
>
|
||||||
Редактировать params
|
Редактировать params
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-sm align-middle mb-0">
|
<table class="table table-sm align-middle mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="([key, value]) in Object.entries(paramsObject(selectedCase))" :key="key">
|
<tr>
|
||||||
<th class="table-light" style="width: 240px">{{ key }}</th>
|
<th
|
||||||
<td>{{ formatValue(value) }}</td>
|
class="table-light"
|
||||||
|
style="width: 240px"
|
||||||
|
v-for="[key, value] in visibleEntries(
|
||||||
|
paramsObject(selectedCase),
|
||||||
|
)"
|
||||||
|
:key="key"
|
||||||
|
>
|
||||||
|
{{ key }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
v-for="[key, value] in visibleEntries(
|
||||||
|
paramsObject(selectedCase),
|
||||||
|
)"
|
||||||
|
:key="key"
|
||||||
|
>
|
||||||
|
{{ formatValue(value) }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -405,26 +553,28 @@ onBeforeUnmount(() => {
|
|||||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
:class="psiViewModeModel === 'time' ? 'btn-primary' : 'btn-outline-primary'"
|
:class="
|
||||||
|
psiViewMode === 'time' ? 'btn-primary' : 'btn-outline-primary'
|
||||||
|
"
|
||||||
type="button"
|
type="button"
|
||||||
@click="psiViewModeModel = 'time'"
|
@click="psiViewMode = 'time'"
|
||||||
:disabled="!selectedCase"
|
:disabled="!selectedCase"
|
||||||
>
|
>
|
||||||
Time
|
Time
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
:class="psiViewModeModel === 'fft' ? 'btn-primary' : 'btn-outline-primary'"
|
:class="psiViewMode === 'fft' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
type="button"
|
type="button"
|
||||||
@click="psiViewModeModel = 'fft'"
|
@click="psiViewMode = 'fft'"
|
||||||
:disabled="!selectedCaseSpectrum"
|
:disabled="!selectedCaseSpectrum"
|
||||||
>
|
>
|
||||||
FFT
|
FFT
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="position-relative" style="min-height: 360px;">
|
<div class="position-relative" style="min-height: 360px">
|
||||||
<div ref="caseChartRef" style="height: 360px; width: 100%;"></div>
|
<div ref="caseChartRef" style="height: 360px; width: 100%"></div>
|
||||||
<div
|
<div
|
||||||
v-if="!selectedCase && !selectedCaseLoading && !selectedCaseError"
|
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"
|
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-muted"
|
||||||
@@ -433,41 +583,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
<FieldMapCard :selected-case-id="selectedCaseId" :time="selectedFieldMapTime" />
|
||||||
<button
|
|
||||||
v-for="option in fieldOptions"
|
|
||||||
:key="option.value"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="selectedFieldModel === option.value ? 'btn-primary' : 'btn-outline-primary'"
|
|
||||||
@click="selectedFieldModel = 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -478,21 +594,27 @@ onBeforeUnmount(() => {
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@click.self="emit('closeParamsEditModal')"
|
@click.self="closeParamsEditModal"
|
||||||
>
|
>
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
<div
|
||||||
|
class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"
|
||||||
|
>
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small">Редактирование параметров расчета</div>
|
<div class="text-muted small">
|
||||||
<h2 class="modal-title h5 mb-0">Params #{{ selectedCaseParams.id }}</h2>
|
Редактирование параметров расчета
|
||||||
|
</div>
|
||||||
|
<h2 class="modal-title h5 mb-0">
|
||||||
|
Params #{{ selectedCaseParams.id }}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
:disabled="paramsSaving"
|
:disabled="paramsSaving"
|
||||||
@click="emit('closeParamsEditModal')"
|
@click="closeParamsEditModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -501,13 +623,16 @@ onBeforeUnmount(() => {
|
|||||||
:form="paramsForm"
|
:form="paramsForm"
|
||||||
:initial-conditions="initialConditions"
|
:initial-conditions="initialConditions"
|
||||||
:saving="paramsSaving"
|
:saving="paramsSaving"
|
||||||
@submit="emit('saveParams')"
|
@submit="saveParams"
|
||||||
@cancel="emit('closeParamsEditModal')"
|
@cancel="closeParamsEditModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="paramsEditModalOpen && selectedCaseParams" class="modal-backdrop fade show"></div>
|
<div
|
||||||
|
v-if="paramsEditModalOpen && selectedCaseParams"
|
||||||
|
class="modal-backdrop fade show"
|
||||||
|
></div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,39 +15,36 @@ defineEmits<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="row g-3" @submit.prevent="$emit('submit')">
|
<form class="d-flex flex-column gap-3" @submit.prevent="$emit('submit')">
|
||||||
<div class="col-12 text-muted small">
|
<div>
|
||||||
Created: {{ new Date(item.created_at).toLocaleString() }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label">rel</label>
|
<label class="form-label">rel</label>
|
||||||
<input v-model="form.rel" type="number" step="any" class="form-control" />
|
<input v-model="form.rel" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div>
|
||||||
<label class="form-label">rel_c</label>
|
<label class="form-label">rel_c</label>
|
||||||
<input v-model="form.rel_c" type="number" step="any" class="form-control" />
|
<input v-model="form.rel_c" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div>
|
||||||
<label class="form-label">le</label>
|
<label class="form-label">le</label>
|
||||||
<input v-model="form.le" type="number" step="any" class="form-control" />
|
<input v-model="form.le" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div>
|
||||||
<label class="form-label">sc</label>
|
<label class="form-label">sc</label>
|
||||||
<input v-model="form.sc" type="number" step="any" class="form-control" />
|
<input v-model="form.sc" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div>
|
||||||
<label class="form-label">pe</label>
|
<label class="form-label">pe</label>
|
||||||
<input v-model="form.pe" type="number" step="any" class="form-control" />
|
<input v-model="form.pe" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div>
|
||||||
<label class="form-label">ma</label>
|
<label class="form-label">ma</label>
|
||||||
<input v-model="form.ma" type="number" step="any" class="form-control" />
|
<input v-model="form.ma" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div>
|
||||||
<label class="form-label">time</label>
|
<label class="form-label">time</label>
|
||||||
<input v-model="form.time" type="number" step="any" class="form-control" />
|
<input v-model="form.time" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9">
|
<div>
|
||||||
<label class="form-label">initial_condition</label>
|
<label class="form-label">initial_condition</label>
|
||||||
<select v-model="form.initial_condition_id" class="form-select">
|
<select v-model="form.initial_condition_id" class="form-select">
|
||||||
<option :value="null">Не задано</option>
|
<option :value="null">Не задано</option>
|
||||||
@@ -56,11 +53,7 @@ defineEmits<{
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
<label class="form-label">folder_path</label>
|
|
||||||
<input v-model="form.folder_path" type="text" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12 d-flex justify-content-end gap-2">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" :disabled="saving" @click="$emit('cancel')">
|
<button class="btn btn-outline-secondary" type="button" :disabled="saving" @click="$emit('cancel')">
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,295 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref, toRaw, watch } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { analizeApi, controlCaseApi, initialConditionApi, paramsApi } from '@/api.ts'
|
|
||||||
import { formatApiError } from '@/api_client.ts'
|
|
||||||
import AnalizeGraphCard from '@/components/analize/AnalizeGraphCard.vue'
|
import AnalizeGraphCard from '@/components/analize/AnalizeGraphCard.vue'
|
||||||
import SelectedControlCaseCard from '@/components/analize/SelectedControlCaseCard.vue'
|
import SelectedControlCaseCard from '@/components/analize/SelectedControlCaseCard.vue'
|
||||||
import { useNotify } from '@/composables/useNotify.ts'
|
|
||||||
import type { ControlCase, InitialCondition, Params } from '@/models.ts'
|
|
||||||
|
|
||||||
const notify = useNotify()
|
|
||||||
type PsiViewMode = 'time' | 'fft'
|
|
||||||
type AnalizePoint = { case_id: number; case_name: string; x: number; omega: number; psi_max: number }
|
|
||||||
type GroupedSeries = { group_value: number; group_label: string; points: AnalizePoint[] }
|
|
||||||
|
|
||||||
const selectedParameter = ref('Rel')
|
|
||||||
const groupParameter = ref('')
|
|
||||||
const showTrend = ref(false)
|
|
||||||
const filterParameter = ref('')
|
|
||||||
const filterMin = ref('')
|
|
||||||
const filterMax = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const paramsSaving = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const chartTitle = ref('')
|
|
||||||
const seriesData = ref<AnalizePoint[]>([])
|
|
||||||
const groupedSeriesData = ref<GroupedSeries[]>([])
|
|
||||||
const selectedCaseId = ref<number | null>(null)
|
const selectedCaseId = ref<number | null>(null)
|
||||||
const selectedCase = ref<ControlCase | null>(null)
|
const graphRefreshToken = ref(0)
|
||||||
const selectedCaseLoading = ref(false)
|
|
||||||
const selectedCaseError = ref('')
|
|
||||||
const selectedCasePoints = ref<{ t: number; psi_m: number }[]>([])
|
|
||||||
const selectedCasePsiLPoints = ref<{ t: number; psi_l: number }[]>([])
|
|
||||||
const selectedCaseSpectrum = ref<{
|
|
||||||
time_step: number
|
|
||||||
points: {
|
|
||||||
psi_m: { frequency: number; amplitude: number }[]
|
|
||||||
psi_l: { frequency: number; amplitude: number }[]
|
|
||||||
}
|
|
||||||
} | null>(null)
|
|
||||||
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('psi')
|
|
||||||
const psiViewMode = ref<PsiViewMode>('time')
|
|
||||||
const initialConditions = ref<InitialCondition[]>([])
|
|
||||||
const paramsEditModalOpen = ref(false)
|
|
||||||
const chartControlsReady = ref(false)
|
|
||||||
const paramsForm = reactive<Params>({
|
|
||||||
id: 0,
|
|
||||||
created_at: '',
|
|
||||||
updated_at: '',
|
|
||||||
deleted_at: null,
|
|
||||||
rel: 0,
|
|
||||||
rel_c: 0,
|
|
||||||
le: 0,
|
|
||||||
sc: 0,
|
|
||||||
pe: 0,
|
|
||||||
ma: 0,
|
|
||||||
initial_condition_id: null,
|
|
||||||
initial_condition: null,
|
|
||||||
time: 0,
|
|
||||||
folder_path: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
function selectedCaseParams() {
|
function refreshGraph() {
|
||||||
const record = (selectedCase.value ?? {}) as Record<string, unknown>
|
graphRefreshToken.value += 1
|
||||||
return (record.params ?? record.Params ?? null) as Params | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInitialConditions() {
|
|
||||||
try {
|
|
||||||
initialConditions.value = await initialConditionApi.listAll()
|
|
||||||
} catch (err) {
|
|
||||||
notify.error(formatApiError(err, 'Не удалось загрузить начальные условия.'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openParamsEditModal() {
|
|
||||||
const params = selectedCaseParams()
|
|
||||||
if (!params) return
|
|
||||||
Object.assign(paramsForm, { ...toRaw(params) })
|
|
||||||
paramsEditModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeParamsEditModal() {
|
|
||||||
if (paramsSaving.value) return
|
|
||||||
paramsEditModalOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveParams() {
|
|
||||||
const params = selectedCaseParams()
|
|
||||||
if (!params) return
|
|
||||||
|
|
||||||
paramsSaving.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await paramsApi.update(params.id, {
|
|
||||||
rel: Number(paramsForm.rel),
|
|
||||||
rel_c: Number(paramsForm.rel_c),
|
|
||||||
le: Number(paramsForm.le),
|
|
||||||
sc: Number(paramsForm.sc),
|
|
||||||
pe: Number(paramsForm.pe),
|
|
||||||
ma: Number(paramsForm.ma),
|
|
||||||
initial_condition_id: paramsForm.initial_condition_id,
|
|
||||||
time: Number(paramsForm.time),
|
|
||||||
folder_path: paramsForm.folder_path,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (selectedCase.value) {
|
|
||||||
selectedCase.value = { ...selectedCase.value, params: updated, Params: updated } as ControlCase
|
|
||||||
}
|
|
||||||
paramsEditModalOpen.value = false
|
|
||||||
await loadData()
|
|
||||||
} catch (err) {
|
|
||||||
notify.error(formatApiError(err, 'Не удалось сохранить params.'))
|
|
||||||
} finally {
|
|
||||||
paramsSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await analizeApi.series(selectedParameter.value, {
|
|
||||||
group_parameter: groupParameter.value || undefined,
|
|
||||||
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
|
|
||||||
groupedSeriesData.value = (response.groups ?? []).map((group) => ({
|
|
||||||
group_value: group.group_value,
|
|
||||||
group_label: group.group_label,
|
|
||||||
points: group.points.map((point) => ({
|
|
||||||
case_id: point.case_id,
|
|
||||||
case_name: point.case_name,
|
|
||||||
x: point.x,
|
|
||||||
omega: point.omega,
|
|
||||||
psi_max: point.psi_max,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
const responsePoints = response.points ?? groupedSeriesData.value.flatMap((group) => group.points)
|
|
||||||
seriesData.value = responsePoints.map((point) => ({
|
|
||||||
case_id: point.case_id,
|
|
||||||
x: point.x,
|
|
||||||
omega: point.omega,
|
|
||||||
psi_max: point.psi_max,
|
|
||||||
case_name: point.case_name,
|
|
||||||
}))
|
|
||||||
} catch (err) {
|
|
||||||
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
|
|
||||||
notify.error(error.value)
|
|
||||||
seriesData.value = []
|
|
||||||
groupedSeriesData.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSelectedCase(caseId: number) {
|
|
||||||
selectedCaseId.value = caseId
|
|
||||||
selectedCaseLoading.value = true
|
|
||||||
selectedCaseError.value = ''
|
|
||||||
selectedCase.value = null
|
|
||||||
selectedCasePoints.value = []
|
|
||||||
selectedCasePsiLPoints.value = []
|
|
||||||
selectedCaseSpectrum.value = null
|
|
||||||
selectedFieldMap.value = null
|
|
||||||
selectedFieldMapError.value = ''
|
|
||||||
selectedFieldMapLoading.value = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [caseData, csvData, spectrumData] = await Promise.all([
|
|
||||||
controlCaseApi.retrieve(caseId),
|
|
||||||
controlCaseApi.chartData(caseId),
|
|
||||||
controlCaseApi.psiSpectrum(caseId),
|
|
||||||
])
|
|
||||||
|
|
||||||
selectedCase.value = caseData
|
|
||||||
|
|
||||||
const tIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 't')
|
|
||||||
const psiMIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 'psi_m')
|
|
||||||
const psiLIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 'psi_l')
|
|
||||||
|
|
||||||
if (tIndex < 0 || psiMIndex < 0 || psiLIndex < 0) {
|
|
||||||
throw new Error('CSV does not contain t/psi_m/psi_l 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))
|
|
||||||
|
|
||||||
selectedCasePsiLPoints.value = csvData.rows
|
|
||||||
.map((row) => ({
|
|
||||||
t: Number(row[tIndex]),
|
|
||||||
psi_l: Number(row[psiLIndex]),
|
|
||||||
}))
|
|
||||||
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_l))
|
|
||||||
|
|
||||||
selectedCaseSpectrum.value = spectrumData
|
|
||||||
} catch (err) {
|
|
||||||
selectedCaseError.value = formatApiError(err, 'Не удалось загрузить расчетный случай.')
|
|
||||||
notify.error(selectedCaseError.value)
|
|
||||||
} finally {
|
|
||||||
selectedCaseLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFieldMap(time: number) {
|
|
||||||
if (!selectedCaseId.value) return
|
|
||||||
|
|
||||||
selectedFieldMapLoading.value = true
|
|
||||||
selectedFieldMapError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
selectedFieldMap.value = await controlCaseApi.fieldMap(selectedCaseId.value, time)
|
|
||||||
} catch (err) {
|
|
||||||
selectedFieldMapError.value = formatApiError(err, 'Не удалось загрузить карту полей.')
|
|
||||||
notify.error(selectedFieldMapError.value)
|
|
||||||
selectedFieldMap.value = null
|
|
||||||
} finally {
|
|
||||||
selectedFieldMapLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
filterParameter.value = ''
|
|
||||||
filterMin.value = ''
|
|
||||||
filterMax.value = ''
|
|
||||||
void loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([loadData(), loadInitialConditions()])
|
|
||||||
chartControlsReady.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedParameter, () => chartControlsReady.value && void loadData())
|
|
||||||
watch(groupParameter, () => chartControlsReady.value && void loadData())
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="d-flex flex-column gap-3">
|
<section class="d-flex flex-column gap-3">
|
||||||
<AnalizeGraphCard
|
<AnalizeGraphCard :refresh-token="graphRefreshToken" @select-case="selectedCaseId = $event" />
|
||||||
v-model:selected-parameter="selectedParameter"
|
<SelectedControlCaseCard :selected-case-id="selectedCaseId" @params-saved="refreshGraph" />
|
||||||
v-model:group-parameter="groupParameter"
|
|
||||||
v-model:show-trend="showTrend"
|
|
||||||
v-model:filter-parameter="filterParameter"
|
|
||||||
v-model:filter-min="filterMin"
|
|
||||||
v-model:filter-max="filterMax"
|
|
||||||
:loading="loading"
|
|
||||||
:error="error"
|
|
||||||
:chart-title="chartTitle"
|
|
||||||
:series-data="seriesData"
|
|
||||||
:grouped-series-data="groupedSeriesData"
|
|
||||||
@refresh="loadData"
|
|
||||||
@reset-filters="resetFilters"
|
|
||||||
@select-case="loadSelectedCase"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectedControlCaseCard
|
|
||||||
v-model:selected-field="selectedField"
|
|
||||||
v-model:psi-view-mode="psiViewMode"
|
|
||||||
:selected-case-id="selectedCaseId"
|
|
||||||
:selected-case="selectedCase"
|
|
||||||
:selected-case-loading="selectedCaseLoading"
|
|
||||||
:selected-case-error="selectedCaseError"
|
|
||||||
:selected-case-points="selectedCasePoints"
|
|
||||||
:selected-case-psi-l-points="selectedCasePsiLPoints"
|
|
||||||
:selected-case-spectrum="selectedCaseSpectrum"
|
|
||||||
:selected-field-map="selectedFieldMap"
|
|
||||||
:selected-field-map-loading="selectedFieldMapLoading"
|
|
||||||
:selected-field-map-error="selectedFieldMapError"
|
|
||||||
:initial-conditions="initialConditions"
|
|
||||||
:params-edit-modal-open="paramsEditModalOpen"
|
|
||||||
:params-saving="paramsSaving"
|
|
||||||
:params-form="paramsForm"
|
|
||||||
@load-field-map="loadFieldMap"
|
|
||||||
@open-params-edit-modal="openParamsEditModal"
|
|
||||||
@close-params-edit-modal="closeParamsEditModal"
|
|
||||||
@save-params="saveParams"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ import type { ControlCase } from "@/models.ts";
|
|||||||
import { controlCaseStatusMeta } from "@/statuses.ts";
|
import { controlCaseStatusMeta } from "@/statuses.ts";
|
||||||
import { useModelCollection } from "@/composables/useModelCollection.ts";
|
import { useModelCollection } from "@/composables/useModelCollection.ts";
|
||||||
|
|
||||||
const { items, loading, error, page, totalCount, totalPages, loadItems, goToPage } =
|
const {
|
||||||
useModelCollection<ControlCase>(controlCaseApi, {
|
items,
|
||||||
loadErrorMessage: "Не удалось загрузить control_case.",
|
loading,
|
||||||
});
|
error,
|
||||||
|
page,
|
||||||
|
totalCount,
|
||||||
|
totalPages,
|
||||||
|
loadItems,
|
||||||
|
goToPage,
|
||||||
|
} = useModelCollection<ControlCase>(controlCaseApi, {
|
||||||
|
loadErrorMessage: "Не удалось загрузить control_case.",
|
||||||
|
});
|
||||||
|
|
||||||
const selectedStatus = ref("");
|
const selectedStatus = ref("");
|
||||||
const selectedCaseIds = ref<number[]>([]);
|
const selectedCaseIds = ref<number[]>([]);
|
||||||
@@ -19,15 +27,21 @@ const restarting = ref(false);
|
|||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
const total = computed(() => totalCount.value);
|
const total = computed(() => totalCount.value);
|
||||||
const statusOptions = ["N", "R", "D"];
|
const statusOptions = ["N", "R", "D"];
|
||||||
const restartableItems = computed(() => items.value.filter((item) => canRestart(item)));
|
const restartableItems = computed(() =>
|
||||||
|
items.value.filter((item) => canRestart(item)),
|
||||||
|
);
|
||||||
const selectedCount = computed(() => selectedCaseIds.value.length);
|
const selectedCount = computed(() => selectedCaseIds.value.length);
|
||||||
const allRestartableSelected = computed(() => {
|
const allRestartableSelected = computed(() => {
|
||||||
const ids = restartableItems.value.map((item) => item.id);
|
const ids = restartableItems.value.map((item) => item.id);
|
||||||
return ids.length > 0 && ids.every((id) => selectedCaseIds.value.includes(id));
|
return (
|
||||||
|
ids.length > 0 && ids.every((id) => selectedCaseIds.value.includes(id))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function filterParams(): ListParams | undefined {
|
function filterParams(): ListParams | undefined {
|
||||||
return selectedStatus.value ? { status: selectedStatus.value } : undefined;
|
let f = { ordering: "-id", status: selectedStatus.value };
|
||||||
|
|
||||||
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFilteredItems(resetPage = false) {
|
async function loadFilteredItems(resetPage = false) {
|
||||||
@@ -61,7 +75,9 @@ function toggleItem(item: ControlCase) {
|
|||||||
if (!canRestart(item)) return;
|
if (!canRestart(item)) return;
|
||||||
|
|
||||||
if (isSelected(item.id)) {
|
if (isSelected(item.id)) {
|
||||||
selectedCaseIds.value = selectedCaseIds.value.filter((id) => id !== item.id);
|
selectedCaseIds.value = selectedCaseIds.value.filter(
|
||||||
|
(id) => id !== item.id,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,16 +89,25 @@ function toggleVisibleItems() {
|
|||||||
if (visibleIds.length === 0) return;
|
if (visibleIds.length === 0) return;
|
||||||
|
|
||||||
if (allRestartableSelected.value) {
|
if (allRestartableSelected.value) {
|
||||||
selectedCaseIds.value = selectedCaseIds.value.filter((id) => !visibleIds.includes(id));
|
selectedCaseIds.value = selectedCaseIds.value.filter(
|
||||||
|
(id) => !visibleIds.includes(id),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedCaseIds.value = Array.from(new Set([...selectedCaseIds.value, ...visibleIds]));
|
selectedCaseIds.value = Array.from(
|
||||||
|
new Set([...selectedCaseIds.value, ...visibleIds]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restartSelectedCases() {
|
async function restartSelectedCases() {
|
||||||
if (selectedCaseIds.value.length === 0) return;
|
if (selectedCaseIds.value.length === 0) return;
|
||||||
if (!window.confirm(`Перезапустить выбранные кейсы (${selectedCaseIds.value.length})? Данные расчетов будут удалены.`)) return;
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Перезапустить выбранные кейсы (${selectedCaseIds.value.length})? Данные расчетов будут удалены.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
restarting.value = true;
|
restarting.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -141,7 +166,11 @@ onMounted(() => loadFilteredItems());
|
|||||||
@click="restartSelectedCases"
|
@click="restartSelectedCases"
|
||||||
:disabled="selectedCount === 0 || restarting"
|
:disabled="selectedCount === 0 || restarting"
|
||||||
>
|
>
|
||||||
{{ restarting ? 'Перезапуск...' : `Перезапустить выбранные (${selectedCount})` }}
|
{{
|
||||||
|
restarting
|
||||||
|
? "Перезапуск..."
|
||||||
|
: `Перезапустить выбранные (${selectedCount})`
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
@@ -196,7 +225,11 @@ onMounted(() => loadFilteredItems());
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isSelected(item.id)"
|
:checked="isSelected(item.id)"
|
||||||
:disabled="!canRestart(item) || restarting"
|
:disabled="!canRestart(item) || restarting"
|
||||||
:title="canRestart(item) ? 'Выбрать для перезапуска' : 'Кейс уже в очереди или выполняется'"
|
:title="
|
||||||
|
canRestart(item)
|
||||||
|
? 'Выбрать для перезапуска'
|
||||||
|
: 'Кейс уже в очереди или выполняется'
|
||||||
|
"
|
||||||
:aria-label="`Выбрать control case ${item.id}`"
|
:aria-label="`Выбрать control case ${item.id}`"
|
||||||
@change="toggleItem(item)"
|
@change="toggleItem(item)"
|
||||||
/>
|
/>
|
||||||
@@ -231,9 +264,7 @@ onMounted(() => loadFilteredItems());
|
|||||||
<div
|
<div
|
||||||
class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top"
|
class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top"
|
||||||
>
|
>
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">Всего: {{ totalCount }}</div>
|
||||||
Всего: {{ totalCount }}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="btn-group btn-group-sm"
|
class="btn-group btn-group-sm"
|
||||||
role="group"
|
role="group"
|
||||||
|
|||||||
Reference in New Issue
Block a user