better download
This commit is contained in:
@@ -27,7 +27,11 @@
|
||||
|
||||
import type * as echarts from 'echarts';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||
|
||||
let showLegend = $state(false);
|
||||
|
||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
||||
|
||||
let chartComponents: EChart[] = $state([]);
|
||||
let chartInstances: echarts.ECharts[] = $state([]);
|
||||
@@ -35,12 +39,8 @@
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
let showLegend = $state(false);
|
||||
let averageOnly = $state(false);
|
||||
|
||||
const location = get(storedLocation);
|
||||
|
||||
// Local component state for chart configuration
|
||||
let params = $state({
|
||||
latitude: [52.52],
|
||||
longitude: [13.41],
|
||||
@@ -49,6 +49,18 @@
|
||||
models: ['gfs_seamless']
|
||||
});
|
||||
|
||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||
|
||||
interface FetchedData {
|
||||
hourly: Record<string, unknown>;
|
||||
hourly_units: Record<string, string>;
|
||||
utc_offset_seconds: number;
|
||||
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
let fetchedData: FetchedData | null = $state(null);
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
onMount(() => {
|
||||
@@ -67,32 +79,31 @@
|
||||
chartInstances = [...chartInstances, chart];
|
||||
}
|
||||
|
||||
// ─── Data Loading & Chart Building ──────────────────────────────────────────
|
||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
||||
|
||||
$effect(() => {
|
||||
const loadData = async () => {
|
||||
if (!mounted) return;
|
||||
const hourlyVars = params.hourly;
|
||||
const modelList = params.models;
|
||||
|
||||
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
||||
|
||||
const loadData = async () => {
|
||||
loading = true;
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
|
||||
// Fetch sunrise/sunset from the standard forecast API
|
||||
const dataDaily = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
|
||||
);
|
||||
const wd = await dataDaily.json();
|
||||
const [dataDaily, dataReq] = await Promise.all([
|
||||
fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
|
||||
),
|
||||
fetch(
|
||||
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&forecast_days=14`
|
||||
)
|
||||
]);
|
||||
|
||||
// Fetch ensemble data
|
||||
const dataReq = await fetch(
|
||||
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&forecast_days=14`
|
||||
);
|
||||
const data = await dataReq.json();
|
||||
const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]);
|
||||
|
||||
// ─── Compute daylight bands ─────────────────────────────────────
|
||||
|
||||
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
|
||||
[];
|
||||
let markAreas: FetchedData['markAreas'] = [];
|
||||
|
||||
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
|
||||
markAreas = buildDaylightMarkAreas(
|
||||
@@ -102,90 +113,99 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Build chart options for each variable ──────────────────────
|
||||
|
||||
const colors = getThemeColors();
|
||||
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
||||
const variableCount = params.hourly?.length || 0;
|
||||
const newOptions: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (let vi = 0; vi < variableCount; vi++) {
|
||||
const variable = params.hourly![vi];
|
||||
const unit = findUnit(data.hourly_units, data.hourly, variable);
|
||||
const timeLength = data.hourly.time.length;
|
||||
fetchedData = {
|
||||
hourly: data.hourly,
|
||||
hourly_units: data.hourly_units,
|
||||
utc_offset_seconds: data.utc_offset_seconds,
|
||||
markAreas,
|
||||
timestamps
|
||||
};
|
||||
|
||||
// ─── Calculate average and spread ───────────────────────────
|
||||
|
||||
const { average } = calculateAverage(data.hourly, variable, timeLength);
|
||||
const { minValues, maxValues } = calculateSpread(data.hourly, variable, timeLength);
|
||||
|
||||
// ─── Build series ───────────────────────────────────────────
|
||||
|
||||
const series: Array<Record<string, unknown>> = [];
|
||||
|
||||
// Ensemble spread (min/max area)
|
||||
const spreadData: Array<[number, number, number]> = minValues.map(
|
||||
(min, index) =>
|
||||
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
||||
);
|
||||
|
||||
series.push(...buildSpreadSeries({ variable, spreadData }));
|
||||
|
||||
// Average line
|
||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||
|
||||
// Current time marker
|
||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
|
||||
|
||||
// Daylight bands
|
||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||
if (daylightSeries) {
|
||||
series.push(daylightSeries);
|
||||
}
|
||||
|
||||
// ─── Compose final option ───────────────────────────────────
|
||||
|
||||
const isFirst = vi === 0;
|
||||
const isLast = vi === variableCount - 1;
|
||||
|
||||
const option = composeChartOption({
|
||||
title: isFirst
|
||||
? {
|
||||
text: 'Model Spread',
|
||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||
}
|
||||
: null,
|
||||
tooltip: { unit },
|
||||
legend: {
|
||||
show: showLegend,
|
||||
data: [variable + '_average']
|
||||
},
|
||||
grid: {
|
||||
hasTitle: isFirst,
|
||||
hasSubtitle: isFirst,
|
||||
showLegend
|
||||
},
|
||||
yAxis: { unit },
|
||||
series,
|
||||
toolbox: {
|
||||
saveAsImage: true,
|
||||
fileName: `14-day-forecast-${variable}`,
|
||||
format: 'png'
|
||||
},
|
||||
showCredit: isLast,
|
||||
colors
|
||||
});
|
||||
|
||||
newOptions.push(option);
|
||||
}
|
||||
|
||||
chartOptions = newOptions;
|
||||
loading = false;
|
||||
};
|
||||
|
||||
loadData();
|
||||
});
|
||||
|
||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
||||
|
||||
$effect(() => {
|
||||
if (!fetchedData) return;
|
||||
|
||||
const {
|
||||
hourly: hourlyData,
|
||||
hourly_units,
|
||||
utc_offset_seconds,
|
||||
markAreas,
|
||||
timestamps
|
||||
} = fetchedData;
|
||||
const _showLegend = showLegend;
|
||||
|
||||
const colors = getThemeColors();
|
||||
const variableCount = params.hourly?.length || 0;
|
||||
const timeLength = (hourlyData.time as number[]).length;
|
||||
const newOptions: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (let vi = 0; vi < variableCount; vi++) {
|
||||
const variable = params.hourly![vi];
|
||||
const unit = findUnit(hourly_units, hourlyData, variable);
|
||||
|
||||
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
||||
const { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength);
|
||||
|
||||
const series: Array<Record<string, unknown>> = [];
|
||||
|
||||
const spreadData: Array<[number, number, number]> = minValues.map(
|
||||
(min, index) =>
|
||||
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
||||
);
|
||||
|
||||
series.push(...buildSpreadSeries({ variable, spreadData }));
|
||||
|
||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||
|
||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
||||
|
||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||
if (daylightSeries) {
|
||||
series.push(daylightSeries);
|
||||
}
|
||||
|
||||
const isFirst = vi === 0;
|
||||
const isLast = vi === variableCount - 1;
|
||||
|
||||
const option = composeChartOption({
|
||||
title: isFirst
|
||||
? {
|
||||
text: 'Model Spread',
|
||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||
}
|
||||
: null,
|
||||
tooltip: { unit },
|
||||
legend: {
|
||||
show: _showLegend,
|
||||
data: [variable + '_average']
|
||||
},
|
||||
grid: {
|
||||
hasTitle: isFirst,
|
||||
hasSubtitle: isFirst,
|
||||
showLegend: _showLegend
|
||||
},
|
||||
yAxis: { unit },
|
||||
series,
|
||||
toolbox: false,
|
||||
showCredit: isLast,
|
||||
colors
|
||||
});
|
||||
|
||||
newOptions.push(option);
|
||||
}
|
||||
|
||||
chartOptions = newOptions;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||
@@ -211,27 +231,9 @@
|
||||
<ChartToolbar charts={chartInstances} fileName="14-day-forecast">
|
||||
{#snippet controls()}
|
||||
<div class="flex gap-2">
|
||||
<Switch
|
||||
id="show_legend"
|
||||
name="Show legend"
|
||||
bind:checked={showLegend}
|
||||
onCheckedChange={() => {
|
||||
params.hourly = params.hourly;
|
||||
}}
|
||||
/>
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Switch
|
||||
id="average_only"
|
||||
name="Average only"
|
||||
bind:checked={averageOnly}
|
||||
onCheckedChange={() => {
|
||||
params.hourly = params.hourly;
|
||||
}}
|
||||
/>
|
||||
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
</div>
|
||||
|
||||
@@ -29,10 +29,13 @@
|
||||
|
||||
import type * as echarts from 'echarts';
|
||||
|
||||
// Wrap models in array to match template expectation of nested arrays like hourly
|
||||
const models = [modelsFlat];
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||
|
||||
let showLegend = $state(false);
|
||||
|
||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
||||
|
||||
let chartComponents: EChart[] = $state([]);
|
||||
let chartInstances: echarts.ECharts[] = $state([]);
|
||||
@@ -40,9 +43,6 @@
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
let showLegend = $state(false);
|
||||
let averageOnly = $state(false);
|
||||
|
||||
const location = get(storedLocation);
|
||||
|
||||
let params = $state({
|
||||
@@ -59,6 +59,18 @@
|
||||
]
|
||||
});
|
||||
|
||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||
|
||||
interface FetchedData {
|
||||
hourly: Record<string, unknown>;
|
||||
hourly_units: Record<string, string>;
|
||||
utc_offset_seconds: number;
|
||||
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
let fetchedData: FetchedData | null = $state(null);
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
onMount(() => {
|
||||
@@ -77,28 +89,27 @@
|
||||
chartInstances = [...chartInstances, chart];
|
||||
}
|
||||
|
||||
// ─── Data Loading & Chart Building ──────────────────────────────────────────
|
||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
||||
|
||||
$effect(() => {
|
||||
const loadData = async () => {
|
||||
if (!mounted) return;
|
||||
const hourlyVars = params.hourly;
|
||||
const modelList = params.models;
|
||||
|
||||
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
||||
|
||||
const loadData = async () => {
|
||||
loading = true;
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
|
||||
const dataReq = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise`
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise`
|
||||
);
|
||||
const data = await dataReq.json();
|
||||
|
||||
// ─── Compute daylight bands ─────────────────────────────────────
|
||||
|
||||
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
|
||||
[];
|
||||
let markAreas: FetchedData['markAreas'] = [];
|
||||
|
||||
if ('daily' in data) {
|
||||
// Find the first model-suffixed key for sunrise/sunset
|
||||
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
|
||||
dailyFirstModelKey.shift();
|
||||
dailyFirstModelKey = dailyFirstModelKey.join('_');
|
||||
@@ -115,96 +126,104 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Build chart options for each variable ──────────────────────
|
||||
|
||||
const colors = getThemeColors();
|
||||
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
||||
const variableCount = params.hourly?.length || 0;
|
||||
const newOptions: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (let vi = 0; vi < variableCount; vi++) {
|
||||
const variable = params.hourly![vi];
|
||||
const unit = findUnit(data.hourly_units, data.hourly, variable);
|
||||
const timeLength = data.hourly.time.length;
|
||||
fetchedData = {
|
||||
hourly: data.hourly,
|
||||
hourly_units: data.hourly_units,
|
||||
utc_offset_seconds: data.utc_offset_seconds,
|
||||
markAreas,
|
||||
timestamps
|
||||
};
|
||||
|
||||
// ─── Build individual model series ───────────────────────────
|
||||
|
||||
const series: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (!averageOnly) {
|
||||
for (const [model, values] of Object.entries(data.hourly)) {
|
||||
if (model === 'time') continue;
|
||||
if (!model.startsWith(variable)) continue;
|
||||
|
||||
const seriesData = (values as (number | null)[]).map(
|
||||
(val, idx) => [timestamps[idx], val] as [number, number | null]
|
||||
);
|
||||
|
||||
series.push(
|
||||
buildModelSeries({
|
||||
name: model,
|
||||
data: seriesData,
|
||||
unit
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Average series ─────────────────────────────────────────
|
||||
|
||||
const { average } = calculateAverage(data.hourly, variable, timeLength);
|
||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||
|
||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||
|
||||
// ─── Annotation series ───────────────────────────────────────
|
||||
|
||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
|
||||
|
||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||
if (daylightSeries) {
|
||||
series.push(daylightSeries);
|
||||
}
|
||||
|
||||
// ─── Compose final option ───────────────────────────────────
|
||||
|
||||
const isFirst = vi === 0;
|
||||
const isLast = vi === variableCount - 1;
|
||||
|
||||
const option = composeChartOption({
|
||||
title: isFirst
|
||||
? {
|
||||
text: 'Model Compare',
|
||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||
}
|
||||
: null,
|
||||
tooltip: { unit },
|
||||
legend: { show: showLegend },
|
||||
grid: {
|
||||
hasTitle: isFirst,
|
||||
hasSubtitle: isFirst,
|
||||
showLegend
|
||||
},
|
||||
yAxis: { unit },
|
||||
series,
|
||||
toolbox: {
|
||||
saveAsImage: true,
|
||||
fileName: `model-compare-${variable}`,
|
||||
format: 'png'
|
||||
},
|
||||
showCredit: isLast,
|
||||
colors
|
||||
});
|
||||
|
||||
newOptions.push(option);
|
||||
}
|
||||
|
||||
chartOptions = newOptions;
|
||||
loading = false;
|
||||
};
|
||||
|
||||
loadData();
|
||||
});
|
||||
|
||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
||||
|
||||
$effect(() => {
|
||||
if (!fetchedData) return;
|
||||
|
||||
const {
|
||||
hourly: hourlyData,
|
||||
hourly_units,
|
||||
utc_offset_seconds,
|
||||
markAreas,
|
||||
timestamps
|
||||
} = fetchedData;
|
||||
const _showLegend = showLegend;
|
||||
|
||||
const colors = getThemeColors();
|
||||
const variableCount = params.hourly?.length || 0;
|
||||
const timeLength = (hourlyData.time as number[]).length;
|
||||
const newOptions: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (let vi = 0; vi < variableCount; vi++) {
|
||||
const variable = params.hourly![vi];
|
||||
const unit = findUnit(hourly_units, hourlyData, variable);
|
||||
|
||||
const series: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const [model, values] of Object.entries(hourlyData)) {
|
||||
if (model === 'time') continue;
|
||||
if (!model.startsWith(variable)) continue;
|
||||
|
||||
const seriesData = (values as (number | null)[]).map(
|
||||
(val, idx) => [timestamps[idx], val] as [number, number | null]
|
||||
);
|
||||
|
||||
series.push(
|
||||
buildModelSeries({
|
||||
name: model,
|
||||
data: seriesData,
|
||||
unit
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||
|
||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
||||
|
||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||
if (daylightSeries) {
|
||||
series.push(daylightSeries);
|
||||
}
|
||||
|
||||
const isFirst = vi === 0;
|
||||
const isLast = vi === variableCount - 1;
|
||||
|
||||
const option = composeChartOption({
|
||||
title: isFirst
|
||||
? {
|
||||
text: 'Model Compare',
|
||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||
}
|
||||
: null,
|
||||
tooltip: { unit },
|
||||
legend: { show: _showLegend },
|
||||
grid: {
|
||||
hasTitle: isFirst,
|
||||
hasSubtitle: isFirst,
|
||||
showLegend: _showLegend
|
||||
},
|
||||
yAxis: { unit },
|
||||
series,
|
||||
toolbox: false,
|
||||
showCredit: isLast,
|
||||
colors
|
||||
});
|
||||
|
||||
newOptions.push(option);
|
||||
}
|
||||
|
||||
chartOptions = newOptions;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||
@@ -230,27 +249,9 @@
|
||||
<ChartToolbar charts={chartInstances} fileName="model-comparison">
|
||||
{#snippet controls()}
|
||||
<div class="flex gap-2">
|
||||
<Switch
|
||||
id="show_legend"
|
||||
name="Show legend"
|
||||
bind:checked={showLegend}
|
||||
onCheckedChange={() => {
|
||||
params.hourly = params.hourly;
|
||||
}}
|
||||
/>
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Switch
|
||||
id="average_only"
|
||||
name="Average only"
|
||||
bind:checked={averageOnly}
|
||||
onCheckedChange={() => {
|
||||
params.hourly = params.hourly;
|
||||
}}
|
||||
/>
|
||||
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
</div>
|
||||
@@ -289,8 +290,7 @@
|
||||
return item !== value;
|
||||
});
|
||||
} else if (params.models) {
|
||||
params.models.push(value);
|
||||
params.models = params.models;
|
||||
params.models = [...params.models, value];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -344,8 +344,7 @@
|
||||
return item !== value;
|
||||
});
|
||||
} else if (params.hourly) {
|
||||
params.hourly.push(value);
|
||||
params.hourly = params.hourly;
|
||||
params.hourly = [...params.hourly, value];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user