fixes
Some checks failed
CI / rust (push) Failing after 36s
CI / frontend (push) Failing after 1m53s
CI / wasm (push) Successful in 1m46s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped

This commit is contained in:
2026-04-09 02:16:27 +03:00
parent 58a59f2609
commit 92583141c9
24 changed files with 586 additions and 40 deletions

View File

@@ -230,6 +230,26 @@ pub struct ChartAxis {
pub y_label: Option<String>,
pub show_grid: Option<bool>,
pub grid_color: Option<String>,
/// Show vertical grid lines at each category (line charts). Defaults to true.
pub show_vertical_grid: Option<bool>,
pub vertical_grid_color: Option<String>,
#[serde(default)]
pub reference_lines: Vec<ChartReferenceLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartReferenceLine {
/// Category index (0-based) where the vertical line is drawn
pub category_index: usize,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub width: Option<f64>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub dash: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]

View File

@@ -32,6 +32,7 @@ import RichTextProperties from '../properties/RichTextProperties.vue'
import ContainerProperties from '../properties/ContainerProperties.vue'
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
import ChartProperties from '../properties/ChartProperties.vue'
import PropCondition from '../properties/shared/PropCondition.vue'
import '../../styles/properties.css'
const templateStore = useTemplateStore()
@@ -233,6 +234,13 @@ function deleteSelected() {
</div>
</div>
<!-- Condition -->
<PropCondition
v-if="selectedElement.id !== 'root'"
:condition="selectedElement.condition"
@update:condition="(v) => templateStore.updateElement(selectedElement!.id, { condition: v } as any)"
/>
<!-- Delete -->
<div v-if="selectedElement.id !== 'root'" class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button>

View File

@@ -3,6 +3,7 @@ import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema'
import type {
TemplateElement,
TextElement,
RepeatingTableElement,
TableColumn,
ImageElement,
@@ -46,6 +47,18 @@ const tools: ToolItem[] = [
content: 'Yeni metin',
}),
},
{
label: 'Veri Metni',
icon: 'D',
create: (): TextElement => ({
id: nextId('dtxt'),
type: 'text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 11, color: '#000000' },
binding: { type: 'scalar', path: '' },
}),
},
{
label: 'Zengin Metin',
icon: 'R',

View File

@@ -42,10 +42,12 @@ const formatOptions = [
<PropTextStyleGroup
:font-size="style().fontSize ?? 11"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#000000'"
:align="style().align ?? 'left'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>

View File

@@ -247,6 +247,19 @@ function removeColor(index: number) {
:model-value="element.axis?.gridColor ?? '#E5E7EB'"
@update:model-value="(v) => updateNested('axis', 'gridColor', v, {})"
/>
<template v-if="element.chartType === 'line'">
<PropCheckbox
label="Dikey Izgara"
:model-value="element.axis?.showVerticalGrid ?? true"
@update:model-value="(v) => updateNested('axis', 'showVerticalGrid', v, {})"
/>
<PropColorInput
v-if="element.axis?.showVerticalGrid !== false"
label="Dikey Izgara Renk"
:model-value="element.axis?.verticalGridColor ?? '#E5E7EB'"
@update:model-value="(v) => updateNested('axis', 'verticalGridColor', v, {})"
/>
</template>
</PropSection>
<!-- Stil -->
@@ -292,6 +305,12 @@ function removeColor(index: number) {
:min="0.1"
@update:model-value="(v) => updateStyle('lineWidth', v)"
/>
<PropSelect
label="Egri Tipi"
:model-value="element.style.curveType ?? 'linear'"
:options="[{ value: 'linear', label: 'Duz' }, { value: 'smooth', label: 'Yumusak' }]"
@update:model-value="(v) => updateStyle('curveType', v)"
/>
<PropCheckbox
label="Noktalar"
:model-value="element.style.showPoints ?? true"

View File

@@ -1,18 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropCheckbox from './shared/PropCheckbox.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { CheckboxElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CheckboxElement }>()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
const booleanFields = computed(() =>
schemaStore.scalarFields.filter((f) => f.type === 'boolean' || f.type === 'string'),
)
</script>
<template>
<PropSection title="Onay Kutusu">
<PropFieldSelect
label="Veri Alani"
:model-value="element.binding?.path ?? ''"
:fields="booleanFields"
:allow-empty="true"
empty-label="Yok (statik)"
data-tip="Onay durumunun gelecegi veri alani"
@update:model-value="
(v) =>
update({
binding: v ? { type: 'scalar', path: v } : undefined,
checked: v ? undefined : element.checked ?? false,
} as any)
"
/>
<PropCheckbox
v-if="!element.binding"
label="Isaretli"
@@ -40,5 +63,13 @@ const { update, updateStyle } = usePropertyUpdate(() => props.element)
data-tip="Kutu kenarlik rengi"
@update:model-value="(v) => updateStyle('borderColor', v)"
/>
<PropNumberInput
label="Kenar Kalinligi"
:model-value="element.style.borderWidth ?? 0.3"
:step="0.1"
:min="0"
data-tip="Kutu kenarlik kalinligi (mm)"
@update:model-value="(v) => updateStyle('borderWidth', v)"
/>
</PropSection>
</template>

View File

@@ -29,10 +29,13 @@ const formatOptions = [
/>
<PropTextStyleGroup
:font-size="style().fontSize ?? 10"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#666666'"
:align="style().align ?? 'left'"
:show-weight="false"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>

View File

@@ -29,10 +29,13 @@ const formatOptions = [
/>
<PropTextStyleGroup
:font-size="style().fontSize ?? 10"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#666666'"
:align="style().align ?? 'center'"
:show-weight="false"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropSelect from './shared/PropSelect.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { RichTextElement, RichTextSpan, TextStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: RichTextElement }>()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
function updateSpan(index: number, updates: Partial<RichTextSpan>) {
const content = [...props.element.content]
@@ -43,10 +46,13 @@ const weightOptions = [
<PropSection title="Varsayilan Stil">
<PropTextStyleGroup
:font-size="element.style.fontSize ?? 11"
:font-weight="element.style.fontWeight ?? 'normal'"
:font-family="element.style.fontFamily"
:color="element.style.color ?? '#000000'"
:align="element.style.align ?? 'left'"
:show-weight="false"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>
@@ -79,6 +85,15 @@ const weightOptions = [
@input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })"
/>
</div>
<PropFieldSelect
label="Binding"
:model-value="span.binding?.path ?? ''"
:fields="schemaStore.scalarFields"
:allow-empty="true"
empty-label="Yok (statik)"
data-tip="Span'in baglanacagi veri alani"
@update:model-value="(v) => updateSpan(idx, { binding: v ? { type: 'scalar', path: v } : undefined })"
/>
<div class="prop-row" data-tip="Span yazi boyutu bos birakilirsa varsayilan kullanilir">
<label class="prop-label">Boyut</label>
<input
@@ -109,6 +124,13 @@ const weightOptions = [
data-tip="Span metin rengi"
@update:model-value="(v) => updateSpanStyle(idx, 'color', v)"
/>
<PropSelect
label="Hizalama"
:model-value="(span.style as TextStyle).align ?? ''"
:options="[{ value: '', label: 'Varsayilan' }, { value: 'left', label: 'Sol' }, { value: 'center', label: 'Orta' }, { value: 'right', label: 'Sag' }]"
data-tip="Span hizalamasi"
@update:model-value="(v) => updateSpanStyle(idx, 'align', v || undefined)"
/>
</div>
</PropSection>
</template>

View File

@@ -15,6 +15,12 @@ const shapeOptions = [
{ value: 'rounded_rectangle', label: 'Yuvarlak Dikdortgen' },
{ value: 'ellipse', label: 'Elips' },
]
const borderStyleOptions = [
{ value: 'solid', label: 'Duz' },
{ value: 'dashed', label: 'Kesikli' },
{ value: 'dotted', label: 'Noktali' },
]
</script>
<template>
@@ -46,6 +52,13 @@ const shapeOptions = [
data-tip="Kenarlik cizgi kalinligi (mm)"
@update:model-value="(v) => updateStyle('borderWidth', v)"
/>
<PropSelect
label="Kenar Stili"
:model-value="element.style.borderStyle ?? 'solid'"
:options="borderStyleOptions"
data-tip="Kenarlik cizgi stili"
@update:model-value="(v) => updateStyle('borderStyle', v)"
/>
<PropNumberInput
v-if="element.shapeType === 'rounded_rectangle'"
label="Kose Yuvarlakligi"

View File

@@ -18,6 +18,10 @@ function updateSize(axis: 'width' | 'height', sv: SizeValue) {
templateStore.updateElementSize(props.element.id, { [axis]: sv })
}
function updateSizeConstraint(key: string, value: number | undefined) {
templateStore.updateElementSize(props.element.id, { [key]: value })
}
function onTypeChange(axis: 'width' | 'height', type: string) {
if (type === 'auto') updateSize(axis, { type: 'auto' })
else if (type === 'fr') updateSize(axis, { type: 'fr', value: 1 })
@@ -79,5 +83,49 @@ function onTypeChange(axis: 'width' | 'height', type: string) {
data-tip="Sabit yukseklik degeri (mm)"
@update:model-value="(v) => updateSize('height', { type: 'fixed', value: v })"
/>
<PropNumberInput
v-if="element.size.height.type === 'fr'"
label="fr"
:model-value="(element.size.height as any).value"
:step="1"
:min="1"
data-tip="Kalan alani oransal doldurma degeri"
@update:model-value="(v) => updateSize('height', { type: 'fr', value: v })"
/>
</PropSection>
<PropSection title="Min / Max">
<PropNumberInput
label="Min Gen."
:model-value="element.size.minWidth ?? 0"
:step="1"
:min="0"
data-tip="Minimum genislik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('minWidth', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Max Gen."
:model-value="element.size.maxWidth ?? 0"
:step="1"
:min="0"
data-tip="Maksimum genislik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('maxWidth', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Min Yuk."
:model-value="element.size.minHeight ?? 0"
:step="1"
:min="0"
data-tip="Minimum yukseklik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('minHeight', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Max Yuk."
:model-value="element.size.maxHeight ?? 0"
:step="1"
:min="0"
data-tip="Maksimum yukseklik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('maxHeight', v > 0 ? v : undefined)"
/>
</PropSection>
</template>

View File

@@ -1,17 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { StaticTextElement, TextStyle, TemplateElement } from '../../core/types'
import type { StaticTextElement, TextElement, TextStyle, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: TemplateElement }>()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
const style = () => props.element.style as TextStyle
const isText = computed(() => props.element.type === 'text')
</script>
<template>
<PropSection title="Metin Stili">
<PropSection title="Metin">
<div v-if="element.type === 'static_text'" class="prop-row" data-tip="Sabit metin icerigi">
<label class="prop-label">Metin</label>
<input
@@ -21,13 +27,38 @@ const style = () => props.element.style as TextStyle
@input="(e) => update({ content: (e.target as HTMLInputElement).value } as any)"
/>
</div>
<template v-if="isText">
<PropFieldSelect
label="Veri Alani"
:model-value="(element as TextElement).binding?.path ?? ''"
:fields="schemaStore.scalarFields"
data-tip="Metnin baglanacagi veri alani"
@update:model-value="(v) => update({ binding: { type: 'scalar', path: v } } as any)"
/>
<div class="prop-row" data-tip="Veri alaninin onune eklenecek sabit metin">
<label class="prop-label">Ön Ek</label>
<input
class="prop-input"
type="text"
:value="(element as TextElement).content ?? ''"
placeholder="ör: Fatura No: "
@input="(e) => update({ content: (e.target as HTMLInputElement).value || undefined } as any)"
/>
</div>
</template>
</PropSection>
<PropSection title="Metin Stili">
<PropTextStyleGroup
:font-size="style().fontSize ?? 11"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#000000'"
:align="style().align ?? 'left'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSchemaStore } from '../../../stores/schema'
import PropFieldSelect from './PropFieldSelect.vue'
import PropSelect from './PropSelect.vue'
import PropSection from './PropSection.vue'
import type { Condition } from '../../../core/types'
import '../../../styles/properties.css'
const props = defineProps<{
condition?: Condition
}>()
const emit = defineEmits<{
'update:condition': [value: Condition | undefined]
}>()
const schemaStore = useSchemaStore()
const enabled = computed(() => !!props.condition)
const operatorOptions = [
{ value: 'eq', label: '= Esit' },
{ value: 'neq', label: '≠ Esit Degil' },
{ value: 'gt', label: '> Buyuk' },
{ value: 'gte', label: '>= Buyuk Esit' },
{ value: 'lt', label: '< Kucuk' },
{ value: 'lte', label: '<= Kucuk Esit' },
{ value: 'truthy', label: 'Dolu (truthy)' },
{ value: 'falsy', label: 'Bos (falsy)' },
]
const needsValue = computed(() => {
const op = props.condition?.operator
return op && op !== 'truthy' && op !== 'falsy'
})
function toggle(on: boolean) {
if (on) {
emit('update:condition', { path: '', operator: 'truthy' })
} else {
emit('update:condition', undefined)
}
}
function updateField(key: keyof Condition, value: unknown) {
emit('update:condition', { ...props.condition!, [key]: value })
}
</script>
<template>
<PropSection title="Kosullu Gosterim">
<div class="prop-row" data-tip="Elemani belirli bir kosulla goster/gizle">
<label class="prop-label">Aktif</label>
<input type="checkbox" :checked="enabled" @change="toggle(($event.target as HTMLInputElement).checked)" />
</div>
<template v-if="enabled">
<PropFieldSelect
label="Alan"
:model-value="condition!.path"
:fields="schemaStore.scalarFields"
data-tip="Kosulun degerlendirilecegi veri alani"
@update:model-value="(v) => updateField('path', v)"
/>
<PropSelect
label="Operator"
:model-value="condition!.operator"
:options="operatorOptions"
data-tip="Karsilastirma operatoru"
@update:model-value="(v) => updateField('operator', v)"
/>
<div v-if="needsValue" class="prop-row" data-tip="Karsilastirilacak deger">
<label class="prop-label">Deger</label>
<input
class="prop-input"
type="text"
:value="condition!.value ?? ''"
@input="(e) => updateField('value', (e.target as HTMLInputElement).value)"
/>
</div>
</template>
</PropSection>
</template>

View File

@@ -1,26 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../../stores/template'
import PropNumberInput from './PropNumberInput.vue'
import PropColorInput from './PropColorInput.vue'
import PropSelect from './PropSelect.vue'
withDefaults(
const props = withDefaults(
defineProps<{
fontSize: number
fontWeight?: string
fontFamily?: string
color: string
align: string
showWeight?: boolean
}>(),
{ fontWeight: 'normal', showWeight: true },
{ fontWeight: 'normal', fontFamily: undefined, showWeight: true },
)
defineEmits<{
'update:fontSize': [value: number]
'update:fontWeight': [value: string]
'update:fontFamily': [value: string | undefined]
'update:color': [value: string]
'update:align': [value: string]
}>()
const templateStore = useTemplateStore()
const fontOptions = computed(() =>
templateStore.template.fonts.map((f) => ({ value: f, label: f })),
)
const weightOptions = [
{ value: 'normal', label: 'Normal' },
{ value: 'bold', label: 'Kalin' },
@@ -34,6 +44,14 @@ const alignOptions = [
</script>
<template>
<PropSelect
v-if="fontOptions.length > 1"
label="Font"
:model-value="fontFamily ?? fontOptions[0]?.value ?? ''"
:options="fontOptions"
data-tip="Yazi tipi ailesi"
@update:model-value="$emit('update:fontFamily', $event)"
/>
<PropNumberInput
label="Boyut (pt)"
:model-value="fontSize"

View File

@@ -81,7 +81,25 @@ const emit = defineEmits<{
&times;
</button>
</div>
<span class="ts-clbl">Zebra</span>
<span class="ts-clbl">Tek</span>
</div>
<div class="ts-color-item" data-tip="Zebra satir rengi cift satirlar">
<div class="ts-swatch-wrap">
<input
class="ts-swatch"
type="color"
:value="style.zebraEven ?? '#ffffff'"
@input="(e) => emit('update:style', 'zebraEven', (e.target as HTMLInputElement).value)"
/>
<button
v-if="style.zebraEven"
class="ts-swatch-clr"
@click="emit('update:style', 'zebraEven', undefined)"
>
&times;
</button>
</div>
<span class="ts-clbl">Cift</span>
</div>
</div>

View File

@@ -250,11 +250,22 @@ export interface ChartLabels {
color?: string
}
export interface ChartReferenceLine {
categoryIndex: number
color?: string
width?: number
label?: string
dash?: boolean
}
export interface ChartAxis {
xLabel?: string
yLabel?: string
showGrid?: boolean
gridColor?: string
showVerticalGrid?: boolean
verticalGridColor?: string
referenceLines?: ChartReferenceLine[]
}
export interface ChartStyle {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -55,6 +55,10 @@ test-visual-editor:
test-visual: visual-refs
cd frontend && bun run test:visual
# Visual snapshot'lari guncelle (UI degisikliklerinden sonra)
update-snapshots: visual-refs
cd frontend && bun run test:visual -- --update-snapshots
# Tum testler (Rust + frontend unit + visual)
test-all: test-rust test-front test-visual

View File

@@ -129,10 +129,23 @@ pub struct LineChartLayout {
pub show_labels: bool,
pub label_font: f64,
pub label_color: String,
pub smooth: bool,
/// X axis line endpoints
pub x_axis_y: f64,
pub x_axis_x1: f64,
pub x_axis_x2: f64,
/// Vertical reference lines
pub ref_lines: Vec<RefLineLayout>,
}
pub struct RefLineLayout {
pub x: f64,
pub y1: f64,
pub y2: f64,
pub color: String,
pub width: f64,
pub dash: bool,
pub label: Option<String>,
}
pub struct PieSlice {
@@ -223,6 +236,10 @@ pub trait ChartDataSource {
fn inner_radius(&self) -> Option<f64>;
fn show_points(&self) -> Option<bool>;
fn line_width(&self) -> Option<f64>;
fn curve_type(&self) -> Option<&str>;
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine];
fn show_vertical_grid(&self) -> bool;
fn vertical_grid_color(&self) -> Option<&str>;
}
// ---------------------------------------------------------------------------
@@ -314,6 +331,18 @@ impl ChartDataSource for crate::data_resolve::ResolvedChartData {
fn line_width(&self) -> Option<f64> {
self.style.line_width
}
fn curve_type(&self) -> Option<&str> {
self.style.curve_type.as_deref()
}
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine] {
self.axis.as_ref().map_or(&[], |a| &a.reference_lines)
}
fn show_vertical_grid(&self) -> bool {
self.axis.as_ref().and_then(|a| a.show_vertical_grid).unwrap_or(true)
}
fn vertical_grid_color(&self) -> Option<&str> {
self.axis.as_ref().and_then(|a| a.vertical_grid_color.as_deref())
}
}
// ---------------------------------------------------------------------------
@@ -403,6 +432,18 @@ impl ChartDataSource for crate::ChartRenderData {
fn line_width(&self) -> Option<f64> {
self.line_width
}
fn curve_type(&self) -> Option<&str> {
self.curve_type.as_deref()
}
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine] {
&self.reference_lines
}
fn show_vertical_grid(&self) -> bool {
self.show_vertical_grid
}
fn vertical_grid_color(&self) -> Option<&str> {
self.vertical_grid_color.as_deref()
}
}
// ---------------------------------------------------------------------------
@@ -693,22 +734,14 @@ pub fn compute_x_labels_line(
rotate_angle: 0.0,
};
}
let spacing = if n_cats == 1 {
pw
} else {
pw / (n_cats - 1) as f64
};
let step = pw / n_cats as f64;
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
let rotate_angle = compute_label_rotation(max_label_len, spacing);
let rotate_angle = compute_label_rotation(max_label_len, step);
let labels = categories
.iter()
.enumerate()
.map(|(ci, cat)| {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let x = px + step / 2.0 + ci as f64 * step;
XLabel {
text: cat.clone(),
x,
@@ -833,6 +866,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let show_labels = data.show_labels();
let label_font = data.label_font_size().unwrap_or(2.2);
let label_color = data.label_color().unwrap_or("#333").to_string();
let smooth = data.curve_type() == Some("smooth");
// Slot-based positioning: each category gets a slot, point centered in slot
// This adds padding on left/right so first/last points don't touch axes
let step = if n_cats > 0 { pw / n_cats as f64 } else { pw };
let series = (0..data.series_count())
.map(|si| {
@@ -841,11 +879,7 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
.iter()
.enumerate()
.map(|(ci, val)| {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let x = px + step / 2.0 + ci as f64 * step;
let y = py + ph - ((val - min_val) / range) * ph;
LinePoint { x, y, value: *val }
})
@@ -859,6 +893,42 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let x_labels = compute_x_labels_line(data.categories(), px, py + ph, pw);
// Vertical grid lines at each category
let vgrid_color = data.vertical_grid_color().unwrap_or("#E5E7EB").to_string();
let mut ref_lines: Vec<RefLineLayout> = if data.show_vertical_grid() {
(0..n_cats).map(|ci| {
let x = px + step / 2.0 + ci as f64 * step;
RefLineLayout {
x,
y1: py,
y2: py + ph,
color: vgrid_color.clone(),
width: 0.15,
dash: false,
label: None,
}
}).collect()
} else {
vec![]
};
// Explicit reference lines (overlay on top of grid)
for rl in data.reference_lines() {
if rl.category_index >= n_cats {
continue;
}
let x = px + step / 2.0 + rl.category_index as f64 * step;
ref_lines.push(RefLineLayout {
x,
y1: py,
y2: py + ph,
color: rl.color.clone().unwrap_or_else(|| "#9CA3AF".to_string()),
width: rl.width.unwrap_or(0.3),
dash: rl.dash.unwrap_or(true),
label: rl.label.clone(),
});
}
LineChartLayout {
min_val,
max_val,
@@ -870,9 +940,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
show_labels,
label_font,
label_color,
smooth,
x_axis_y: py + ph,
x_axis_x1: px,
x_axis_x2: px + pw,
ref_lines,
}
}

View File

@@ -121,14 +121,13 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
// Y axis
render_y_axis_svg(svg, &ll.y_axis);
let mut label_texts = String::new();
for series_layout in &ll.series {
let color = color_at(&cl.palette, series_layout.color_idx);
let mut points = String::new();
let mut point_circles = String::new();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
if ll.show_points {
write!(
point_circles,
@@ -139,19 +138,75 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
}
if ll.show_labels {
svg_text(svg, pt.x, pt.y - 1.5, ll.label_font, &ll.label_color, SvgAnchor::Middle, &format_value(pt.value));
svg_text(&mut label_texts, pt.x, pt.y - 1.5, ll.label_font, &ll.label_color, SvgAnchor::Middle, &format_value(pt.value));
}
}
if ll.smooth && series_layout.points.len() >= 2 {
// Catmull-Rom → cubic bezier smooth curve
let pts = &series_layout.points;
let mut d = format!("M{:.2},{:.2}", pts[0].x, pts[0].y);
for i in 0..pts.len() - 1 {
let p0 = if i > 0 { &pts[i - 1] } else { &pts[i] };
let p1 = &pts[i];
let p2 = &pts[i + 1];
let p3 = if i + 2 < pts.len() { &pts[i + 2] } else { &pts[i + 1] };
let cp1x = p1.x + (p2.x - p0.x) / 6.0;
let cp1y = p1.y + (p2.y - p0.y) / 6.0;
let cp2x = p2.x - (p3.x - p1.x) / 6.0;
let cp2y = p2.y - (p3.y - p1.y) / 6.0;
write!(d, " C{:.2},{:.2} {:.2},{:.2} {:.2},{:.2}",
cp1x, cp1y, cp2x, cp2y, p2.x, p2.y
).unwrap();
}
write!(
svg,
r##"<path d="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
d, color, ll.line_width
)
.unwrap();
} else {
let mut points = String::new();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
}
write!(
svg,
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
points.trim(), color, ll.line_width
)
.unwrap();
}
svg.push_str(&point_circles);
}
// Data labels (rendered after lines/points so they appear on top)
svg.push_str(&label_texts);
// Reference lines (vertical)
for rl in &ll.ref_lines {
if rl.dash {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}" stroke-dasharray="1.5,1"/>"##,
rl.x, rl.y1, rl.x, rl.y2, rl.color, rl.width
)
.unwrap();
} else {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}"/>"##,
rl.x, rl.y1, rl.x, rl.y2, rl.color, rl.width
)
.unwrap();
}
if let Some(ref label) = rl.label {
svg_text(svg, rl.x, rl.y1 - 1.0, 2.0, &rl.color, SvgAnchor::Middle, label);
}
}
// X axis labels
render_x_labels_svg(svg, &ll.x_labels);
@@ -560,6 +615,9 @@ mod tests {
y_label: Some("Revenue".to_string()),
show_grid: None,
grid_color: None,
show_vertical_grid: None,
vertical_grid_color: None,
reference_lines: vec![],
});
let svg = render_svg(&data, 100.0, 60.0);
@@ -575,6 +633,9 @@ mod tests {
y_label: Some("Y Label".to_string()),
show_grid: None,
grid_color: None,
show_vertical_grid: None,
vertical_grid_color: None,
reference_lines: vec![],
});
let svg = render_svg(&data, 80.0, 80.0);

View File

@@ -161,8 +161,21 @@ pub struct ChartRenderData {
// Title align
#[serde(default)]
pub title_align: Option<String>,
// Curve type for line charts
#[serde(default)]
pub curve_type: Option<String>,
// Vertical reference lines
#[serde(default)]
pub reference_lines: Vec<dreport_core::models::ChartReferenceLine>,
// Vertical grid
#[serde(default = "default_true")]
pub show_vertical_grid: bool,
#[serde(default)]
pub vertical_grid_color: Option<String>,
}
fn default_true() -> bool { true }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartSeriesData {
pub name: String,
@@ -340,6 +353,10 @@ impl From<&data_resolve::ResolvedChartData> for ChartRenderData {
legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size),
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
curve_type: cd.style.curve_type.clone(),
reference_lines: cd.axis.as_ref().map_or_else(Vec::new, |a| a.reference_lines.clone()),
show_vertical_grid: cd.axis.as_ref().and_then(|a| a.show_vertical_grid).unwrap_or(true),
vertical_grid_color: cd.axis.as_ref().and_then(|a| a.vertical_grid_color.clone()),
}
}
}

View File

@@ -1015,6 +1015,27 @@ fn render_chart(
}));
let path = {
let mut pb = PathBuilder::new();
if ll.smooth && points.len() >= 2 {
let pts = &series_layout.points;
pb.move_to(mm(pts[0].x), mm(pts[0].y));
for i in 0..pts.len() - 1 {
let p0 = if i > 0 { &pts[i - 1] } else { &pts[i] };
let p1 = &pts[i];
let p2 = &pts[i + 1];
let p3 = if i + 2 < pts.len() { &pts[i + 2] } else { &pts[i + 1] };
let cp1x = p1.x + (p2.x - p0.x) / 6.0;
let cp1y = p1.y + (p2.y - p0.y) / 6.0;
let cp2x = p2.x - (p3.x - p1.x) / 6.0;
let cp2y = p2.y - (p3.y - p1.y) / 6.0;
pb.cubic_to(
mm(cp1x), mm(cp1y),
mm(cp2x), mm(cp2y),
mm(p2.x), mm(p2.y),
);
}
} else {
for (i, (lx, ly)) in points.iter().enumerate() {
if i == 0 {
pb.move_to(mm(*lx), mm(*ly));
@@ -1022,6 +1043,7 @@ fn render_chart(
pb.line_to(mm(*lx), mm(*ly));
}
}
}
pb.finish()
};
if let Some(p) = path {
@@ -1055,6 +1077,32 @@ fn render_chart(
}
}
}
// Reference lines (vertical)
for rl in &ll.ref_lines {
let rl_color = parse_color(&rl.color);
chart_line_seg(
surface,
rl.x,
rl.y1,
rl.x,
rl.y2,
rl_color,
(rl.width * 2.5) as f32,
);
if let Some(ref label) = rl.label {
chart_text(
surface,
rl.x,
rl.y1 - 1.0,
label,
2.0,
&rl.color,
ChartTextAlign::Center,
fonts,
measurer,
);
}
}
render_chart_x_labels(surface, &ll.x_labels, fonts, measurer);
let ac = parse_color("#9CA3AF");
chart_line_seg(

File diff suppressed because one or more lines are too long