mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
charts
This commit is contained in:
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[registries.gitea]
|
||||||
|
index = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/"
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -398,6 +398,8 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "dexpr"
|
name = "dexpr"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/"
|
||||||
|
checksum = "66f1b8752c5d700b0399128c3ba4d5cad1204be8b29de8489d2c4b3c53f975c8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
|
|||||||
@@ -166,6 +166,95 @@ pub struct RichTextSpan {
|
|||||||
pub style: TextStyle,
|
pub style: TextStyle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chart ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ChartType {
|
||||||
|
Bar,
|
||||||
|
Line,
|
||||||
|
Pie,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum GroupMode {
|
||||||
|
Grouped,
|
||||||
|
Stacked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ChartTitle {
|
||||||
|
pub text: String,
|
||||||
|
pub font_size: Option<f64>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub align: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ChartLegend {
|
||||||
|
pub show: bool,
|
||||||
|
pub position: Option<String>,
|
||||||
|
pub font_size: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ChartLabels {
|
||||||
|
pub show: bool,
|
||||||
|
pub font_size: Option<f64>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ChartAxis {
|
||||||
|
pub x_label: Option<String>,
|
||||||
|
pub y_label: Option<String>,
|
||||||
|
pub show_grid: Option<bool>,
|
||||||
|
pub grid_color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ChartStyle {
|
||||||
|
pub colors: Option<Vec<String>>,
|
||||||
|
pub background_color: Option<String>,
|
||||||
|
pub bar_gap: Option<f64>,
|
||||||
|
pub line_width: Option<f64>,
|
||||||
|
pub show_points: Option<bool>,
|
||||||
|
pub curve_type: Option<String>,
|
||||||
|
pub inner_radius: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChartElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub chart_type: ChartType,
|
||||||
|
pub data_source: ArrayBinding,
|
||||||
|
pub category_field: String,
|
||||||
|
pub value_field: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub group_field: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub group_mode: Option<GroupMode>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: Option<ChartTitle>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub legend: Option<ChartLegend>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub labels: Option<ChartLabels>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub axis: Option<ChartAxis>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub style: ChartStyle,
|
||||||
|
}
|
||||||
|
|
||||||
// --- Element tipleri ---
|
// --- Element tipleri ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -205,6 +294,8 @@ pub enum TemplateElement {
|
|||||||
CalculatedText(CalculatedTextElement),
|
CalculatedText(CalculatedTextElement),
|
||||||
#[serde(rename = "rich_text")]
|
#[serde(rename = "rich_text")]
|
||||||
RichText(RichTextElement),
|
RichText(RichTextElement),
|
||||||
|
#[serde(rename = "chart")]
|
||||||
|
Chart(ChartElement),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TemplateElement {
|
impl TemplateElement {
|
||||||
@@ -224,6 +315,7 @@ impl TemplateElement {
|
|||||||
Self::Checkbox(e) => &e.id,
|
Self::Checkbox(e) => &e.id,
|
||||||
Self::CalculatedText(e) => &e.id,
|
Self::CalculatedText(e) => &e.id,
|
||||||
Self::RichText(e) => &e.id,
|
Self::RichText(e) => &e.id,
|
||||||
|
Self::Chart(e) => &e.id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +335,7 @@ impl TemplateElement {
|
|||||||
Self::Checkbox(e) => &e.position,
|
Self::Checkbox(e) => &e.position,
|
||||||
Self::CalculatedText(e) => &e.position,
|
Self::CalculatedText(e) => &e.position,
|
||||||
Self::RichText(e) => &e.position,
|
Self::RichText(e) => &e.position,
|
||||||
|
Self::Chart(e) => &e.position,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +363,7 @@ impl TemplateElement {
|
|||||||
Self::Checkbox(e) => &e.size,
|
Self::Checkbox(e) => &e.size,
|
||||||
Self::CalculatedText(e) => &e.size,
|
Self::CalculatedText(e) => &e.size,
|
||||||
Self::RichText(e) => &e.size,
|
Self::RichText(e) => &e.size,
|
||||||
|
Self::Chart(e) => &e.size,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,6 +386,22 @@ const defaultInvoiceTemplate: Template = {
|
|||||||
borderWidth: 0.5,
|
borderWidth: 0.5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// --- Kalem Tutarlari Grafik ---
|
||||||
|
{
|
||||||
|
id: 'el_chart_bar',
|
||||||
|
type: 'chart',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.fr(), height: sz.fixed(60) },
|
||||||
|
chartType: 'bar',
|
||||||
|
dataSource: { type: 'array', path: 'kalemler' },
|
||||||
|
categoryField: 'adi',
|
||||||
|
valueField: 'tutar',
|
||||||
|
title: { text: 'Kalem Tutarlari', fontSize: 3.5, color: '#1e293b', align: 'center' },
|
||||||
|
legend: { show: false },
|
||||||
|
labels: { show: true, fontSize: 2.2, color: '#333' },
|
||||||
|
axis: { showGrid: true },
|
||||||
|
style: { colors: ['#4F46E5', '#10B981', '#F59E0B', '#EF4444'] },
|
||||||
|
},
|
||||||
// --- Toplamlar ---
|
// --- Toplamlar ---
|
||||||
{
|
{
|
||||||
id: 'c_toplamlar_row',
|
id: 'c_toplamlar_row',
|
||||||
|
|||||||
@@ -301,6 +301,22 @@ watch(
|
|||||||
:style="{ ...elStyle(el), ...shapeStyle(el) }"
|
:style="{ ...elStyle(el), ...shapeStyle(el) }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div
|
||||||
|
v-else-if="el.element_type === 'chart'"
|
||||||
|
class="layout-el layout-el--chart"
|
||||||
|
:style="elStyle(el)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="el.content?.type === 'chart' && el.content.svg"
|
||||||
|
v-html="el.content.svg"
|
||||||
|
style="width: 100%; height: 100%;"
|
||||||
|
/>
|
||||||
|
<div v-else class="layout-el__placeholder" :style="{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', color: '#94a3b8', fontSize: '12px' }">
|
||||||
|
Grafik
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
CheckboxElement,
|
CheckboxElement,
|
||||||
CalculatedTextElement,
|
CalculatedTextElement,
|
||||||
RichTextElement,
|
RichTextElement,
|
||||||
|
ChartElement,
|
||||||
} from '../../core/types'
|
} from '../../core/types'
|
||||||
import PositioningProperties from '../properties/PositioningProperties.vue'
|
import PositioningProperties from '../properties/PositioningProperties.vue'
|
||||||
import SizeProperties from '../properties/SizeProperties.vue'
|
import SizeProperties from '../properties/SizeProperties.vue'
|
||||||
@@ -30,6 +31,7 @@ import CalculatedTextProperties from '../properties/CalculatedTextProperties.vue
|
|||||||
import RichTextProperties from '../properties/RichTextProperties.vue'
|
import RichTextProperties from '../properties/RichTextProperties.vue'
|
||||||
import ContainerProperties from '../properties/ContainerProperties.vue'
|
import ContainerProperties from '../properties/ContainerProperties.vue'
|
||||||
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
|
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
|
||||||
|
import ChartProperties from '../properties/ChartProperties.vue'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
@@ -62,6 +64,7 @@ const elementTypeLabel = computed(() => {
|
|||||||
case 'calculated_text': return 'Hesaplanan Metin'
|
case 'calculated_text': return 'Hesaplanan Metin'
|
||||||
case 'rich_text': return 'Zengin Metin'
|
case 'rich_text': return 'Zengin Metin'
|
||||||
case 'page_break': return 'Sayfa Sonu'
|
case 'page_break': return 'Sayfa Sonu'
|
||||||
|
case 'chart': return 'Grafik'
|
||||||
default: return 'Eleman'
|
default: return 'Eleman'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -160,6 +163,10 @@ function deleteElement() {
|
|||||||
v-if="selectedElement.type === 'repeating_table'"
|
v-if="selectedElement.type === 'repeating_table'"
|
||||||
:element="(selectedElement as RepeatingTableElement)" />
|
:element="(selectedElement as RepeatingTableElement)" />
|
||||||
|
|
||||||
|
<ChartProperties
|
||||||
|
v-if="selectedElement.type === 'chart'"
|
||||||
|
:element="(selectedElement as ChartElement)" />
|
||||||
|
|
||||||
<!-- Header/Footer toggles for root element -->
|
<!-- Header/Footer toggles for root element -->
|
||||||
<div v-if="selectedElement.id === 'root'" class="prop-section">
|
<div v-if="selectedElement.id === 'root'" class="prop-section">
|
||||||
<div class="prop-section__title">Sayfa Ust/Alt Bilgi</div>
|
<div class="prop-section__title">Sayfa Ust/Alt Bilgi</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { useEditorStore } from '../../stores/editor'
|
||||||
import { useSchemaStore } from '../../stores/schema'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement, PageBreakElement, CurrentDateElement, ShapeElement, CheckboxElement, CalculatedTextElement, RichTextElement } from '../../core/types'
|
import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement, PageBreakElement, CurrentDateElement, ShapeElement, CheckboxElement, CalculatedTextElement, RichTextElement, ChartElement } from '../../core/types'
|
||||||
import { sz } from '../../core/types'
|
import { sz } from '../../core/types'
|
||||||
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||||
|
|
||||||
@@ -199,6 +199,38 @@ const tools: ToolItem[] = [
|
|||||||
format: 'DD.MM.YYYY',
|
format: 'DD.MM.YYYY',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Grafik',
|
||||||
|
icon: '◩',
|
||||||
|
create: (): ChartElement => {
|
||||||
|
const arrays = schemaStore.arrayFields
|
||||||
|
const firstArray = arrays[0]
|
||||||
|
let dataPath = ''
|
||||||
|
let categoryField = ''
|
||||||
|
let valueField = ''
|
||||||
|
|
||||||
|
if (firstArray) {
|
||||||
|
dataPath = firstArray.path
|
||||||
|
const itemFields = schemaStore.getArrayItemFields(firstArray.path)
|
||||||
|
const stringField = itemFields.find(f => f.type === 'string')
|
||||||
|
const numberField = itemFields.find(f => f.type === 'number' || f.type === 'integer')
|
||||||
|
categoryField = stringField?.key ?? itemFields[0]?.key ?? ''
|
||||||
|
valueField = numberField?.key ?? itemFields[1]?.key ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId('chart'),
|
||||||
|
type: 'chart',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.fr(1), height: sz.fixed(80) },
|
||||||
|
chartType: 'bar',
|
||||||
|
dataSource: { type: 'array', path: dataPath },
|
||||||
|
categoryField,
|
||||||
|
valueField,
|
||||||
|
style: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Sayfa Sonu',
|
label: 'Sayfa Sonu',
|
||||||
icon: '⏎',
|
icon: '⏎',
|
||||||
|
|||||||
314
frontend/src/components/properties/ChartProperties.vue
Normal file
314
frontend/src/components/properties/ChartProperties.vue
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useTemplateStore } from '../../stores/template'
|
||||||
|
import { useEditorStore } from '../../stores/editor'
|
||||||
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
|
import type { ChartElement, ChartType, GroupMode, TemplateElement } from '../../core/types'
|
||||||
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
|
const props = defineProps<{ element: ChartElement }>()
|
||||||
|
const templateStore = useTemplateStore()
|
||||||
|
const editorStore = useEditorStore()
|
||||||
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
|
function update(updates: Partial<ChartElement>) {
|
||||||
|
const id = editorStore.selectedElementId
|
||||||
|
if (!id) return
|
||||||
|
templateStore.updateElement(id, updates as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStyle(key: string, value: unknown) {
|
||||||
|
const newStyle = { ...props.element.style, [key]: value }
|
||||||
|
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
|
||||||
|
update({ style: newStyle })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema'daki array alanlari
|
||||||
|
const arrayFields = computed(() => schemaStore.arrayFields)
|
||||||
|
|
||||||
|
// Secili array'in item alanlari
|
||||||
|
const itemFields = computed(() => {
|
||||||
|
const path = props.element.dataSource?.path
|
||||||
|
if (!path) return []
|
||||||
|
return schemaStore.getArrayItemFields(path)
|
||||||
|
})
|
||||||
|
|
||||||
|
const stringFields = computed(() => itemFields.value.filter(f => f.type === 'string'))
|
||||||
|
const numberFields = computed(() => itemFields.value.filter(f => f.type === 'number' || f.type === 'integer'))
|
||||||
|
|
||||||
|
function updateDataSource(path: string) {
|
||||||
|
const fields = schemaStore.getArrayItemFields(path)
|
||||||
|
const strField = fields.find(f => f.type === 'string')
|
||||||
|
const numField = fields.find(f => f.type === 'number' || f.type === 'integer')
|
||||||
|
update({
|
||||||
|
dataSource: { type: 'array', path },
|
||||||
|
categoryField: strField?.key ?? fields[0]?.key ?? '',
|
||||||
|
valueField: numField?.key ?? fields[1]?.key ?? '',
|
||||||
|
groupField: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTitle(key: string, value: unknown) {
|
||||||
|
const current = props.element.title ?? { text: '' }
|
||||||
|
update({ title: { ...current, [key]: value } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLegend(key: string, value: unknown) {
|
||||||
|
const current = props.element.legend ?? { show: false }
|
||||||
|
update({ legend: { ...current, [key]: value } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLabels(key: string, value: unknown) {
|
||||||
|
const current = props.element.labels ?? { show: false }
|
||||||
|
update({ labels: { ...current, [key]: value } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAxis(key: string, value: unknown) {
|
||||||
|
const current = props.element.axis ?? {}
|
||||||
|
update({ axis: { ...current, [key]: value } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPie = computed(() => props.element.chartType === 'pie')
|
||||||
|
const hasGroup = computed(() => !!props.element.groupField)
|
||||||
|
|
||||||
|
// Renk paleti (default 6 renk)
|
||||||
|
const colorList = computed(() => {
|
||||||
|
return props.element.style.colors ?? ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateColor(index: number, value: string) {
|
||||||
|
const colors = [...colorList.value]
|
||||||
|
colors[index] = value
|
||||||
|
updateStyle('colors', colors)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addColor() {
|
||||||
|
const colors = [...colorList.value, '#6B7280']
|
||||||
|
updateStyle('colors', colors)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeColor(index: number) {
|
||||||
|
const colors = colorList.value.filter((_, i) => i !== index)
|
||||||
|
updateStyle('colors', colors.length > 0 ? colors : undefined)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chart-properties">
|
||||||
|
<!-- Grafik Tipi -->
|
||||||
|
<div class="prop-section">
|
||||||
|
<div class="prop-section__title">Grafik Tipi</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<select class="prop-input prop-select" :value="element.chartType" @change="update({ chartType: ($event.target as HTMLSelectElement).value as ChartType })">
|
||||||
|
<option value="bar">Bar</option>
|
||||||
|
<option value="line">Line</option>
|
||||||
|
<option value="pie">Pie</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Veri Kaynagi -->
|
||||||
|
<div class="prop-section">
|
||||||
|
<div class="prop-section__title">Veri Kaynagi</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Array</label>
|
||||||
|
<select class="prop-input prop-select" :value="element.dataSource?.path ?? ''" @change="updateDataSource(($event.target as HTMLSelectElement).value)">
|
||||||
|
<option value="" disabled>Sec...</option>
|
||||||
|
<option v-for="arr in arrayFields" :key="arr.path" :value="arr.path">{{ arr.title || arr.path }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Kategori</label>
|
||||||
|
<select class="prop-input prop-select" :value="element.categoryField" @change="update({ categoryField: ($event.target as HTMLSelectElement).value })">
|
||||||
|
<option v-for="f in itemFields" :key="f.key" :value="f.key">{{ f.title || f.key }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Deger</label>
|
||||||
|
<select class="prop-input prop-select" :value="element.valueField" @change="update({ valueField: ($event.target as HTMLSelectElement).value })">
|
||||||
|
<option v-for="f in numberFields" :key="f.key" :value="f.key">{{ f.title || f.key }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Gruplama</label>
|
||||||
|
<select class="prop-input prop-select" :value="element.groupField ?? ''" @change="update({ groupField: ($event.target as HTMLSelectElement).value || undefined })">
|
||||||
|
<option value="">Yok</option>
|
||||||
|
<option v-for="f in stringFields" :key="f.key" :value="f.key">{{ f.title || f.key }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasGroup && !isPie" class="prop-row">
|
||||||
|
<label class="prop-label">Grup Modu</label>
|
||||||
|
<select class="prop-input prop-select" :value="element.groupMode ?? 'grouped'" @change="update({ groupMode: ($event.target as HTMLSelectElement).value as GroupMode })">
|
||||||
|
<option value="grouped">Yan Yana</option>
|
||||||
|
<option value="stacked">Yigin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Baslik -->
|
||||||
|
<div class="prop-section">
|
||||||
|
<div class="prop-section__title">Baslik</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Metin</label>
|
||||||
|
<input class="prop-input" type="text" :value="element.title?.text ?? ''" @change="updateTitle('text', ($event.target as HTMLInputElement).value)" placeholder="Grafik basligi">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row" v-if="element.title?.text">
|
||||||
|
<label class="prop-label">Boyut</label>
|
||||||
|
<input class="prop-input prop-input--sm" type="number" :value="element.title?.fontSize ?? 4" step="0.5" @change="updateTitle('fontSize', parseFloat(($event.target as HTMLInputElement).value))">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row" v-if="element.title?.text">
|
||||||
|
<label class="prop-label">Renk</label>
|
||||||
|
<input class="prop-color" type="color" :value="element.title?.color ?? '#333333'" @input="updateTitle('color', ($event.target as HTMLInputElement).value)">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row" v-if="element.title?.text">
|
||||||
|
<label class="prop-label">Hiza</label>
|
||||||
|
<select class="prop-input prop-select" :value="element.title?.align ?? 'center'" @change="updateTitle('align', ($event.target as HTMLSelectElement).value)">
|
||||||
|
<option value="left">Sol</option>
|
||||||
|
<option value="center">Orta</option>
|
||||||
|
<option value="right">Sag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gosterge (Legend) -->
|
||||||
|
<div class="prop-section">
|
||||||
|
<div class="prop-section__title">Gosterge</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Goster</label>
|
||||||
|
<input type="checkbox" :checked="element.legend?.show ?? false" @change="updateLegend('show', ($event.target as HTMLInputElement).checked)">
|
||||||
|
</div>
|
||||||
|
<template v-if="element.legend?.show">
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Konum</label>
|
||||||
|
<select class="prop-input prop-select" :value="element.legend?.position ?? 'bottom'" @change="updateLegend('position', ($event.target as HTMLSelectElement).value)">
|
||||||
|
<option value="top">Ust</option>
|
||||||
|
<option value="bottom">Alt</option>
|
||||||
|
<option value="right">Sag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Boyut</label>
|
||||||
|
<input class="prop-input prop-input--sm" type="number" :value="element.legend?.fontSize ?? 2.8" step="0.2" @change="updateLegend('fontSize', parseFloat(($event.target as HTMLInputElement).value))">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etiketler -->
|
||||||
|
<div class="prop-section">
|
||||||
|
<div class="prop-section__title">Etiketler</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Goster</label>
|
||||||
|
<input type="checkbox" :checked="element.labels?.show ?? false" @change="updateLabels('show', ($event.target as HTMLInputElement).checked)">
|
||||||
|
</div>
|
||||||
|
<template v-if="element.labels?.show">
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Boyut</label>
|
||||||
|
<input class="prop-input prop-input--sm" type="number" :value="element.labels?.fontSize ?? 2.2" step="0.2" @change="updateLabels('fontSize', parseFloat(($event.target as HTMLInputElement).value))">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Renk</label>
|
||||||
|
<input class="prop-color" type="color" :value="element.labels?.color ?? '#333333'" @input="updateLabels('color', ($event.target as HTMLInputElement).value)">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Eksenler (pie haric) -->
|
||||||
|
<div class="prop-section" v-if="!isPie">
|
||||||
|
<div class="prop-section__title">Eksenler</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">X Etiketi</label>
|
||||||
|
<input class="prop-input" type="text" :value="element.axis?.xLabel ?? ''" @change="updateAxis('xLabel', ($event.target as HTMLInputElement).value || undefined)" placeholder="X ekseni">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Y Etiketi</label>
|
||||||
|
<input class="prop-input" type="text" :value="element.axis?.yLabel ?? ''" @change="updateAxis('yLabel', ($event.target as HTMLInputElement).value || undefined)" placeholder="Y ekseni">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Izgara</label>
|
||||||
|
<input type="checkbox" :checked="element.axis?.showGrid ?? true" @change="updateAxis('showGrid', ($event.target as HTMLInputElement).checked)">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row" v-if="element.axis?.showGrid !== false">
|
||||||
|
<label class="prop-label">Izgara Renk</label>
|
||||||
|
<input class="prop-color" type="color" :value="element.axis?.gridColor ?? '#E5E7EB'" @input="updateAxis('gridColor', ($event.target as HTMLInputElement).value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stil -->
|
||||||
|
<div class="prop-section">
|
||||||
|
<div class="prop-section__title">Stil</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Arka Plan</label>
|
||||||
|
<input class="prop-color" type="color" :value="element.style.backgroundColor ?? '#FFFFFF'" @input="updateStyle('backgroundColor', ($event.target as HTMLInputElement).value)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Renk Paleti -->
|
||||||
|
<div class="prop-section__subtitle">Renk Paleti</div>
|
||||||
|
<div v-for="(color, i) in colorList" :key="i" class="prop-row">
|
||||||
|
<input class="prop-color" type="color" :value="color" @input="updateColor(i, ($event.target as HTMLInputElement).value)">
|
||||||
|
<button class="prop-btn-sm prop-btn-sm--danger" @click="removeColor(i)" title="Kaldir">×</button>
|
||||||
|
</div>
|
||||||
|
<button class="prop-btn-sm" @click="addColor">+ Renk Ekle</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tipe Ozel -->
|
||||||
|
<div class="prop-section" v-if="element.chartType === 'bar'">
|
||||||
|
<div class="prop-section__title">Bar Ayarlari</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Bar Boslugu</label>
|
||||||
|
<input class="prop-input prop-input--sm" type="number" :value="element.style.barGap ?? 0.2" step="0.05" min="0" max="0.8" @change="updateStyle('barGap', parseFloat(($event.target as HTMLInputElement).value))">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-section" v-if="element.chartType === 'line'">
|
||||||
|
<div class="prop-section__title">Line Ayarlari</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Cizgi Kalinligi</label>
|
||||||
|
<input class="prop-input prop-input--sm" type="number" :value="element.style.lineWidth ?? 0.5" step="0.1" min="0.1" @change="updateStyle('lineWidth', parseFloat(($event.target as HTMLInputElement).value))">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Noktalar</label>
|
||||||
|
<input type="checkbox" :checked="element.style.showPoints ?? true" @change="updateStyle('showPoints', ($event.target as HTMLInputElement).checked)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-section" v-if="element.chartType === 'pie'">
|
||||||
|
<div class="prop-section__title">Pie Ayarlari</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Ic Yaricap</label>
|
||||||
|
<input class="prop-input prop-input--sm" type="number" :value="element.style.innerRadius ?? 0" step="0.05" min="0" max="0.9" @change="updateStyle('innerRadius', parseFloat(($event.target as HTMLInputElement).value))">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row" style="font-size: 11px; color: #94a3b8;">
|
||||||
|
0 = Pie, >0 = Donut
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-properties {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-btn-sm {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-btn-sm:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-btn-sm--danger {
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-btn-sm--danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -217,6 +217,62 @@ export interface PageBreakElement extends BaseElement {
|
|||||||
style: Record<string, never>
|
style: Record<string, never>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chart ---
|
||||||
|
|
||||||
|
export type ChartType = 'bar' | 'line' | 'pie'
|
||||||
|
export type GroupMode = 'grouped' | 'stacked'
|
||||||
|
|
||||||
|
export interface ChartTitle {
|
||||||
|
text: string
|
||||||
|
fontSize?: number
|
||||||
|
color?: string
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartLegend {
|
||||||
|
show: boolean
|
||||||
|
position?: 'top' | 'bottom' | 'right'
|
||||||
|
fontSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartLabels {
|
||||||
|
show: boolean
|
||||||
|
fontSize?: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartAxis {
|
||||||
|
xLabel?: string
|
||||||
|
yLabel?: string
|
||||||
|
showGrid?: boolean
|
||||||
|
gridColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartStyle {
|
||||||
|
colors?: string[]
|
||||||
|
backgroundColor?: string
|
||||||
|
barGap?: number // 0.0-1.0
|
||||||
|
lineWidth?: number // mm
|
||||||
|
showPoints?: boolean
|
||||||
|
curveType?: 'linear' | 'smooth'
|
||||||
|
innerRadius?: number // 0=pie, >0=donut (0-0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartElement extends BaseElement {
|
||||||
|
type: 'chart'
|
||||||
|
chartType: ChartType
|
||||||
|
dataSource: ArrayBinding
|
||||||
|
categoryField: string
|
||||||
|
valueField: string
|
||||||
|
groupField?: string
|
||||||
|
groupMode?: GroupMode
|
||||||
|
title?: ChartTitle
|
||||||
|
legend?: ChartLegend
|
||||||
|
labels?: ChartLabels
|
||||||
|
axis?: ChartAxis
|
||||||
|
style: ChartStyle
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContainerElement extends BaseElement {
|
export interface ContainerElement extends BaseElement {
|
||||||
type: 'container'
|
type: 'container'
|
||||||
direction: 'row' | 'column'
|
direction: 'row' | 'column'
|
||||||
@@ -237,7 +293,7 @@ export interface RepeatingTableElement extends BaseElement {
|
|||||||
repeatHeader?: boolean
|
repeatHeader?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement | PageBreakElement | CurrentDateElement | ShapeElement | CheckboxElement | CalculatedTextElement | RichTextElement
|
export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement | PageBreakElement | CurrentDateElement | ShapeElement | CheckboxElement | CalculatedTextElement | RichTextElement | ChartElement
|
||||||
export type TemplateElement = LeafElement | ContainerElement
|
export type TemplateElement = LeafElement | ContainerElement
|
||||||
|
|
||||||
// --- Template ---
|
// --- Template ---
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dreport-core = { path = "../core" }
|
dreport-core = { path = "../core" }
|
||||||
dexpr = { path = "../../rust-expr" }
|
dexpr = { version = "0.1.0", registry = "gitea" }
|
||||||
taffy = "0.9"
|
taffy = "0.9"
|
||||||
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }
|
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
791
layout-engine/src/chart_render.rs
Normal file
791
layout-engine/src/chart_render.rs
Normal file
@@ -0,0 +1,791 @@
|
|||||||
|
use crate::data_resolve::ResolvedChartData;
|
||||||
|
use dreport_core::models::{ChartType, GroupMode};
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
pub const DEFAULT_COLORS: &[&str] = &[
|
||||||
|
"#4F46E5", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn color_at(palette: &[String], i: usize) -> &str {
|
||||||
|
&palette[i % palette.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mm cinsinden chart SVG uret
|
||||||
|
pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> String {
|
||||||
|
let mut svg = String::with_capacity(4096);
|
||||||
|
|
||||||
|
let bg = data
|
||||||
|
.style
|
||||||
|
.background_color
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("#FFFFFF");
|
||||||
|
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {} {}" width="100%" height="100%">"##,
|
||||||
|
width_mm, height_mm
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<rect width="{}" height="{}" fill="{}"/>"##,
|
||||||
|
width_mm, height_mm, bg
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Max sayida renk: kategoriler + seriler
|
||||||
|
let n_colors = data.categories.len().max(data.series.len()).max(1);
|
||||||
|
let palette: Vec<String> = (0..n_colors)
|
||||||
|
.map(|i| {
|
||||||
|
if let Some(ref user_colors) = data.style.colors {
|
||||||
|
if i < user_colors.len() {
|
||||||
|
return user_colors[i].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Margin hesaplari
|
||||||
|
let mut margin_top = 2.0_f64;
|
||||||
|
let mut margin_bottom = 4.0_f64;
|
||||||
|
let mut margin_left = 8.0_f64;
|
||||||
|
let margin_right = 4.0_f64;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
if let Some(ref title) = data.title {
|
||||||
|
if !title.text.is_empty() {
|
||||||
|
let font_size = title.font_size.unwrap_or(4.0);
|
||||||
|
margin_top += font_size * 0.4 + 2.0;
|
||||||
|
let color = title.color.as_deref().unwrap_or("#333333");
|
||||||
|
let align = title.align.as_deref().unwrap_or("center");
|
||||||
|
let x = match align {
|
||||||
|
"left" => margin_left,
|
||||||
|
"right" => width_mm - margin_right,
|
||||||
|
_ => width_mm / 2.0,
|
||||||
|
};
|
||||||
|
let anchor = match align {
|
||||||
|
"left" => "start",
|
||||||
|
"right" => "end",
|
||||||
|
_ => "middle",
|
||||||
|
};
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="{}" font-weight="bold">{}</text>"##,
|
||||||
|
x,
|
||||||
|
margin_top - 1.0,
|
||||||
|
font_size,
|
||||||
|
color,
|
||||||
|
anchor,
|
||||||
|
escape_xml(&title.text)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend space
|
||||||
|
let legend_show = data.legend.as_ref().is_some_and(|l| l.show);
|
||||||
|
let legend_pos = data
|
||||||
|
.legend
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|l| l.position.as_deref())
|
||||||
|
.unwrap_or("bottom");
|
||||||
|
let legend_font = data
|
||||||
|
.legend
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|l| l.font_size)
|
||||||
|
.unwrap_or(2.8);
|
||||||
|
|
||||||
|
if legend_show && data.series.len() > 1 {
|
||||||
|
match legend_pos {
|
||||||
|
"top" => margin_top += legend_font + 3.0,
|
||||||
|
"bottom" => margin_bottom += legend_font + 3.0,
|
||||||
|
_ => {} // right — icerde handle edilecek
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis labels icin yer ac (bar ve line)
|
||||||
|
let has_axis = !matches!(data.chart_type, ChartType::Pie);
|
||||||
|
if has_axis {
|
||||||
|
if data.axis.as_ref().and_then(|a| a.x_label.as_ref()).is_some() {
|
||||||
|
margin_bottom += 4.0;
|
||||||
|
}
|
||||||
|
if data.axis.as_ref().and_then(|a| a.y_label.as_ref()).is_some() {
|
||||||
|
margin_left += 4.0;
|
||||||
|
}
|
||||||
|
// Category labels icin alt bosluk
|
||||||
|
let max_label_len = data.categories.iter().map(|c| c.len()).max().unwrap_or(0);
|
||||||
|
let n_cats = data.categories.len();
|
||||||
|
let available_w = width_mm - margin_left - margin_right;
|
||||||
|
let cat_width = if n_cats > 0 {
|
||||||
|
available_w / n_cats as f64
|
||||||
|
} else {
|
||||||
|
available_w
|
||||||
|
};
|
||||||
|
let max_chars_fit = (cat_width / 1.25).max(1.0) as usize;
|
||||||
|
let will_rotate = max_label_len > max_chars_fit;
|
||||||
|
if will_rotate {
|
||||||
|
// Rotated labels (-45°): dikey ≈ text_width * sin(45°), yatay ≈ text_width * cos(45°)
|
||||||
|
let char_w_mm = 1.1;
|
||||||
|
let max_text_w = max_label_len as f64 * char_w_mm;
|
||||||
|
let label_v = max_text_w * 0.707; // sin(45°)
|
||||||
|
margin_bottom += label_v.min(25.0).max(6.0);
|
||||||
|
// Sol taraftaki label yana tasabilir
|
||||||
|
let label_h = max_text_w * 0.707; // cos(45°)
|
||||||
|
let extra_left = (label_h - cat_width / 2.0).max(0.0);
|
||||||
|
margin_left += extra_left.min(10.0);
|
||||||
|
} else {
|
||||||
|
margin_bottom += 4.0;
|
||||||
|
}
|
||||||
|
// Y-axis value labels icin sol bosluk
|
||||||
|
margin_left += 6.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plot_x = margin_left;
|
||||||
|
let plot_y = margin_top;
|
||||||
|
let plot_w = (width_mm - margin_left - margin_right).max(1.0);
|
||||||
|
let plot_h = (height_mm - margin_top - margin_bottom).max(1.0);
|
||||||
|
|
||||||
|
match data.chart_type {
|
||||||
|
ChartType::Bar => render_bar(&mut svg, data, &palette, plot_x, plot_y, plot_w, plot_h),
|
||||||
|
ChartType::Line => render_line(&mut svg, data, &palette, plot_x, plot_y, plot_w, plot_h),
|
||||||
|
ChartType::Pie => render_pie(&mut svg, data, &palette, width_mm, height_mm, plot_x, plot_y, plot_w, plot_h),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend render
|
||||||
|
if legend_show && data.series.len() > 1 {
|
||||||
|
render_legend(&mut svg, data, &palette, legend_pos, legend_font, width_mm, height_mm, margin_left, margin_top, plot_w, plot_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis labels
|
||||||
|
if has_axis {
|
||||||
|
if let Some(ref axis) = data.axis {
|
||||||
|
if let Some(ref x_label) = axis.x_label {
|
||||||
|
let x = plot_x + plot_w / 2.0;
|
||||||
|
let y = height_mm - 2.0;
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle">{}</text>"##,
|
||||||
|
x, y, escape_xml(x_label)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
if let Some(ref y_label) = axis.y_label {
|
||||||
|
let x = 3.0;
|
||||||
|
let y = plot_y + plot_h / 2.0;
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle" transform="rotate(-90,{:.2},{:.2})">{}</text>"##,
|
||||||
|
x, y, x, y, escape_xml(y_label)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.push_str("</svg>");
|
||||||
|
svg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_bar(
|
||||||
|
svg: &mut String,
|
||||||
|
data: &ResolvedChartData,
|
||||||
|
palette: &[String],
|
||||||
|
px: f64,
|
||||||
|
py: f64,
|
||||||
|
pw: f64,
|
||||||
|
ph: f64,
|
||||||
|
) {
|
||||||
|
if data.categories.is_empty() || data.series.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stacked = matches!(data.group_mode, Some(GroupMode::Stacked));
|
||||||
|
let (min_val, max_val) = value_range(data, stacked);
|
||||||
|
|
||||||
|
let show_grid = data.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true);
|
||||||
|
let grid_color = data
|
||||||
|
.axis
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|a| a.grid_color.as_deref())
|
||||||
|
.unwrap_or("#E5E7EB");
|
||||||
|
|
||||||
|
// Grid + Y axis labels
|
||||||
|
render_y_axis(svg, min_val, max_val, px, py, pw, ph, show_grid, grid_color);
|
||||||
|
|
||||||
|
let n_cats = data.categories.len();
|
||||||
|
let n_series = data.series.len();
|
||||||
|
let cat_width = pw / n_cats as f64;
|
||||||
|
let bar_gap = data.style.bar_gap.unwrap_or(0.2).clamp(0.0, 0.8);
|
||||||
|
let group_width = cat_width * (1.0 - bar_gap);
|
||||||
|
|
||||||
|
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
|
||||||
|
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(2.2);
|
||||||
|
let label_color = data
|
||||||
|
.labels
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|l| l.color.as_deref())
|
||||||
|
.unwrap_or("#333");
|
||||||
|
|
||||||
|
let range = if (max_val - min_val).abs() < 1e-10 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
max_val - min_val
|
||||||
|
};
|
||||||
|
|
||||||
|
for ci in 0..data.categories.len() {
|
||||||
|
let cat_x = px + ci as f64 * cat_width;
|
||||||
|
|
||||||
|
if stacked {
|
||||||
|
let mut y_offset = 0.0_f64;
|
||||||
|
for (si, series) in data.series.iter().enumerate() {
|
||||||
|
let val = series.values.get(ci).copied().unwrap_or(0.0);
|
||||||
|
let bar_h = (val / range) * ph;
|
||||||
|
let bar_y = py + ph - y_offset - bar_h;
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" rx="0.5"/>"##,
|
||||||
|
cat_x + cat_width * bar_gap / 2.0,
|
||||||
|
bar_y,
|
||||||
|
group_width,
|
||||||
|
bar_h.max(0.0),
|
||||||
|
color_at(palette,si)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
if show_labels && val > 0.0 {
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
|
||||||
|
cat_x + cat_width / 2.0,
|
||||||
|
bar_y + bar_h / 2.0 + label_font * 0.15,
|
||||||
|
label_font,
|
||||||
|
label_color,
|
||||||
|
format_value(val)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
y_offset += bar_h;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Grouped
|
||||||
|
let bar_w = group_width / n_series as f64;
|
||||||
|
for (si, series) in data.series.iter().enumerate() {
|
||||||
|
let val = series.values.get(ci).copied().unwrap_or(0.0);
|
||||||
|
let bar_h = ((val - min_val) / range) * ph;
|
||||||
|
let bar_x = cat_x + cat_width * bar_gap / 2.0 + si as f64 * bar_w;
|
||||||
|
let bar_y = py + ph - bar_h;
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" rx="0.5"/>"##,
|
||||||
|
bar_x,
|
||||||
|
bar_y,
|
||||||
|
bar_w.max(0.1),
|
||||||
|
bar_h.max(0.0),
|
||||||
|
color_at(palette,si)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
if show_labels {
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
|
||||||
|
bar_x + bar_w / 2.0,
|
||||||
|
bar_y - 0.8,
|
||||||
|
label_font,
|
||||||
|
label_color,
|
||||||
|
format_value(val)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// X axis labels — rotate if too many categories
|
||||||
|
render_x_labels(svg, &data.categories, px, py + ph, pw, n_cats);
|
||||||
|
|
||||||
|
// X axis line
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#9CA3AF" stroke-width="0.3"/>"##,
|
||||||
|
px, py + ph, px + pw, py + ph
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_line(
|
||||||
|
svg: &mut String,
|
||||||
|
data: &ResolvedChartData,
|
||||||
|
palette: &[String],
|
||||||
|
px: f64,
|
||||||
|
py: f64,
|
||||||
|
pw: f64,
|
||||||
|
ph: f64,
|
||||||
|
) {
|
||||||
|
if data.categories.is_empty() || data.series.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (min_val, max_val) = value_range(data, false);
|
||||||
|
let range = if (max_val - min_val).abs() < 1e-10 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
max_val - min_val
|
||||||
|
};
|
||||||
|
|
||||||
|
let show_grid = data.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true);
|
||||||
|
let grid_color = data
|
||||||
|
.axis
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|a| a.grid_color.as_deref())
|
||||||
|
.unwrap_or("#E5E7EB");
|
||||||
|
render_y_axis(svg, min_val, max_val, px, py, pw, ph, show_grid, grid_color);
|
||||||
|
|
||||||
|
let n_cats = data.categories.len();
|
||||||
|
let line_w = data.style.line_width.unwrap_or(0.5);
|
||||||
|
let show_points = data.style.show_points.unwrap_or(true);
|
||||||
|
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
|
||||||
|
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(2.2);
|
||||||
|
let label_color = data
|
||||||
|
.labels
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|l| l.color.as_deref())
|
||||||
|
.unwrap_or("#333");
|
||||||
|
|
||||||
|
for (si, series) in data.series.iter().enumerate() {
|
||||||
|
let color = color_at(palette,si);
|
||||||
|
let mut points = String::new();
|
||||||
|
let mut point_circles = String::new();
|
||||||
|
|
||||||
|
for (ci, val) in series.values.iter().enumerate() {
|
||||||
|
let x = if n_cats == 1 {
|
||||||
|
px + pw / 2.0
|
||||||
|
} else {
|
||||||
|
px + ci as f64 * pw / (n_cats - 1) as f64
|
||||||
|
};
|
||||||
|
let y = py + ph - ((val - min_val) / range) * ph;
|
||||||
|
write!(points, "{:.2},{:.2} ", x, y).unwrap();
|
||||||
|
|
||||||
|
if show_points {
|
||||||
|
write!(
|
||||||
|
point_circles,
|
||||||
|
r##"<circle cx="{:.2}" cy="{:.2}" r="0.8" fill="{}" stroke="white" stroke-width="0.3"/>"##,
|
||||||
|
x, y, color
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if show_labels {
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
|
||||||
|
x, y - 1.5, label_font, label_color, format_value(*val)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
|
||||||
|
points.trim(),
|
||||||
|
color,
|
||||||
|
line_w
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
svg.push_str(&point_circles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X axis labels — for line chart, spacing is different
|
||||||
|
render_x_labels_line(svg, &data.categories, px, py + ph, pw, n_cats);
|
||||||
|
|
||||||
|
// Axis lines
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#9CA3AF" stroke-width="0.3"/>"##,
|
||||||
|
px, py + ph, px + pw, py + ph
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pie(
|
||||||
|
svg: &mut String,
|
||||||
|
data: &ResolvedChartData,
|
||||||
|
palette: &[String],
|
||||||
|
_total_w: f64,
|
||||||
|
_total_h: f64,
|
||||||
|
px: f64,
|
||||||
|
py: f64,
|
||||||
|
pw: f64,
|
||||||
|
ph: f64,
|
||||||
|
) {
|
||||||
|
// Pie icin ilk serinin degerlerini kullan (veya tum serilerin toplamlarini)
|
||||||
|
let values: Vec<f64> = if data.series.len() == 1 {
|
||||||
|
data.series[0].values.clone()
|
||||||
|
} else {
|
||||||
|
// Birden fazla seri varsa, her kategori icin toplam al
|
||||||
|
data.categories
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ci, _)| {
|
||||||
|
data.series
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.values.get(ci).copied().unwrap_or(0.0))
|
||||||
|
.sum()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let total: f64 = values.iter().sum();
|
||||||
|
if total <= 0.0 || data.categories.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cx = px + pw / 2.0;
|
||||||
|
let cy = py + ph / 2.0;
|
||||||
|
let radius = pw.min(ph) / 2.0 * 0.9;
|
||||||
|
let inner_frac = data.style.inner_radius.unwrap_or(0.0).clamp(0.0, 0.9);
|
||||||
|
let inner_r = radius * inner_frac;
|
||||||
|
|
||||||
|
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
|
||||||
|
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(2.5);
|
||||||
|
let label_color = data
|
||||||
|
.labels
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|l| l.color.as_deref())
|
||||||
|
.unwrap_or("#333");
|
||||||
|
|
||||||
|
let mut start_angle = -std::f64::consts::FRAC_PI_2; // 12 o'clock
|
||||||
|
|
||||||
|
for (i, val) in values.iter().enumerate() {
|
||||||
|
if *val <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sweep = (val / total) * std::f64::consts::TAU;
|
||||||
|
let end_angle = start_angle + sweep;
|
||||||
|
let large_arc = if sweep > std::f64::consts::PI {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let x1 = cx + radius * start_angle.cos();
|
||||||
|
let y1 = cy + radius * start_angle.sin();
|
||||||
|
let x2 = cx + radius * end_angle.cos();
|
||||||
|
let y2 = cy + radius * end_angle.sin();
|
||||||
|
|
||||||
|
let color = color_at(palette,i);
|
||||||
|
|
||||||
|
if inner_r > 0.0 {
|
||||||
|
// Donut
|
||||||
|
let ix1 = cx + inner_r * start_angle.cos();
|
||||||
|
let iy1 = cy + inner_r * start_angle.sin();
|
||||||
|
let ix2 = cx + inner_r * end_angle.cos();
|
||||||
|
let iy2 = cy + inner_r * end_angle.sin();
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<path d="M {:.2} {:.2} A {:.2} {:.2} 0 {} 1 {:.2} {:.2} L {:.2} {:.2} A {:.2} {:.2} 0 {} 0 {:.2} {:.2} Z" fill="{}" stroke="white" stroke-width="0.3"/>"##,
|
||||||
|
x1, y1, radius, radius, large_arc, x2, y2,
|
||||||
|
ix2, iy2, inner_r, inner_r, large_arc, ix1, iy1,
|
||||||
|
color
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
// Full pie
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<path d="M {:.2} {:.2} L {:.2} {:.2} A {:.2} {:.2} 0 {} 1 {:.2} {:.2} Z" fill="{}" stroke="white" stroke-width="0.3"/>"##,
|
||||||
|
cx, cy, x1, y1, radius, radius, large_arc, x2, y2, color
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label
|
||||||
|
if show_labels {
|
||||||
|
let mid_angle = start_angle + sweep / 2.0;
|
||||||
|
let label_r = if inner_r > 0.0 {
|
||||||
|
(radius + inner_r) / 2.0
|
||||||
|
} else {
|
||||||
|
radius * 0.65
|
||||||
|
};
|
||||||
|
let lx = cx + label_r * mid_angle.cos();
|
||||||
|
let ly = cy + label_r * mid_angle.sin();
|
||||||
|
let pct = (val / total * 100.0).round();
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle" dominant-baseline="central">{}%</text>"##,
|
||||||
|
lx, ly, label_font, label_color, pct
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
start_angle = end_angle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_legend(
|
||||||
|
svg: &mut String,
|
||||||
|
data: &ResolvedChartData,
|
||||||
|
palette: &[String],
|
||||||
|
position: &str,
|
||||||
|
font_size: f64,
|
||||||
|
total_w: f64,
|
||||||
|
total_h: f64,
|
||||||
|
margin_left: f64,
|
||||||
|
margin_top: f64,
|
||||||
|
plot_w: f64,
|
||||||
|
_plot_h: f64,
|
||||||
|
) {
|
||||||
|
let names: Vec<&str> = if matches!(data.chart_type, ChartType::Pie) {
|
||||||
|
data.categories.iter().map(|s| s.as_str()).collect()
|
||||||
|
} else {
|
||||||
|
data.series.iter().map(|s| s.name.as_str()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let item_w = 3.0 + font_size * 0.4; // color rect + gap
|
||||||
|
let spacing = 4.0;
|
||||||
|
|
||||||
|
match position {
|
||||||
|
"top" => {
|
||||||
|
let y = margin_top - font_size - 1.5;
|
||||||
|
let mut x = margin_left;
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<rect x="{:.2}" y="{:.2}" width="2.5" height="2.5" fill="{}" rx="0.3"/>"##,
|
||||||
|
x, y - font_size * 0.3, color_at(palette,i)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
|
||||||
|
x + item_w, y + font_size * 0.3, font_size, escape_xml(name)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
x += item_w + name.len() as f64 * font_size * 0.5 + spacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"right" => {
|
||||||
|
let x = margin_left + plot_w + 4.0;
|
||||||
|
let mut y = margin_top + 2.0;
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<rect x="{:.2}" y="{:.2}" width="2.5" height="2.5" fill="{}" rx="0.3"/>"##,
|
||||||
|
x, y, color_at(palette,i)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
|
||||||
|
x + item_w, y + font_size * 0.7, font_size, escape_xml(name)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
y += font_size + 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// bottom (default)
|
||||||
|
let y = total_h - 3.0;
|
||||||
|
let total_legend_w: f64 = names
|
||||||
|
.iter()
|
||||||
|
.map(|n| item_w + n.len() as f64 * font_size * 0.5 + spacing)
|
||||||
|
.sum::<f64>()
|
||||||
|
- spacing;
|
||||||
|
let mut x = (total_w - total_legend_w) / 2.0;
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<rect x="{:.2}" y="{:.2}" width="2.5" height="2.5" fill="{}" rx="0.3"/>"##,
|
||||||
|
x, y - font_size * 0.3, color_at(palette,i)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
|
||||||
|
x + item_w, y + font_size * 0.3, font_size, escape_xml(name)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
x += item_w + name.len() as f64 * font_size * 0.5 + spacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X-axis labels ortak render — bar chart icin (slot-based spacing)
|
||||||
|
fn render_x_labels(
|
||||||
|
svg: &mut String,
|
||||||
|
categories: &[String],
|
||||||
|
px: f64,
|
||||||
|
baseline_y: f64,
|
||||||
|
pw: f64,
|
||||||
|
n_cats: usize,
|
||||||
|
) {
|
||||||
|
if n_cats == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cat_width = pw / n_cats as f64;
|
||||||
|
let max_chars = (cat_width / 1.25).max(1.0) as usize;
|
||||||
|
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
|
||||||
|
|
||||||
|
for (ci, cat) in categories.iter().enumerate() {
|
||||||
|
let x = px + ci as f64 * cat_width + cat_width / 2.0;
|
||||||
|
let y = baseline_y + 2.5;
|
||||||
|
render_single_x_label(svg, cat, x, y, needs_rotate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X-axis labels — line chart icin (point-based spacing)
|
||||||
|
fn render_x_labels_line(
|
||||||
|
svg: &mut String,
|
||||||
|
categories: &[String],
|
||||||
|
px: f64,
|
||||||
|
baseline_y: f64,
|
||||||
|
pw: f64,
|
||||||
|
n_cats: usize,
|
||||||
|
) {
|
||||||
|
if n_cats == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let spacing = if n_cats == 1 { pw } else { pw / (n_cats - 1) as f64 };
|
||||||
|
let max_chars = (spacing / 1.25).max(1.0) as usize;
|
||||||
|
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
|
||||||
|
|
||||||
|
for (ci, cat) in categories.iter().enumerate() {
|
||||||
|
let x = if n_cats == 1 {
|
||||||
|
px + pw / 2.0
|
||||||
|
} else {
|
||||||
|
px + ci as f64 * pw / (n_cats - 1) as f64
|
||||||
|
};
|
||||||
|
let y = baseline_y + 2.5;
|
||||||
|
render_single_x_label(svg, cat, x, y, needs_rotate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tek bir X-axis label render — rotate gerekiyorsa -45° ile, anchor "end"
|
||||||
|
/// Anchor noktasi bar/point'in tam altinda, text sola yukari dogru uzanir
|
||||||
|
fn render_single_x_label(svg: &mut String, text: &str, x: f64, y: f64, rotate: bool) {
|
||||||
|
if rotate {
|
||||||
|
// -45° rotate, text-anchor="end": text, anchor noktasindan sola-yukari dogru uzanir
|
||||||
|
// Bu sayede text asagi-sola tasmaz, sadece yukari-sola gider (plot area icinde kalir)
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-45,{:.2},{:.2})">{}</text>"##,
|
||||||
|
x, y, x, y, escape_xml(text)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#666" text-anchor="middle">{}</text>"##,
|
||||||
|
x, y, escape_xml(text)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_y_axis(
|
||||||
|
svg: &mut String,
|
||||||
|
min_val: f64,
|
||||||
|
max_val: f64,
|
||||||
|
px: f64,
|
||||||
|
py: f64,
|
||||||
|
pw: f64,
|
||||||
|
ph: f64,
|
||||||
|
show_grid: bool,
|
||||||
|
grid_color: &str,
|
||||||
|
) {
|
||||||
|
let range = if (max_val - min_val).abs() < 1e-10 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
max_val - min_val
|
||||||
|
};
|
||||||
|
let tick_count = 5;
|
||||||
|
for i in 0..=tick_count {
|
||||||
|
let frac = i as f64 / tick_count as f64;
|
||||||
|
let val = min_val + frac * range;
|
||||||
|
let y = py + ph - frac * ph;
|
||||||
|
|
||||||
|
// Label
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<text x="{:.2}" y="{:.2}" font-size="2.3" fill="#666" text-anchor="end">{}</text>"##,
|
||||||
|
px - 1.5,
|
||||||
|
y + 0.8,
|
||||||
|
format_value(val)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Grid line
|
||||||
|
if show_grid {
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="0.15"/>"##,
|
||||||
|
px, y, px + pw, y, grid_color
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y axis line
|
||||||
|
write!(
|
||||||
|
svg,
|
||||||
|
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#9CA3AF" stroke-width="0.3"/>"##,
|
||||||
|
px, py, px, py + ph
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tum serilerdeki min/max deger araligini bul
|
||||||
|
fn value_range(data: &ResolvedChartData, stacked: bool) -> (f64, f64) {
|
||||||
|
if data.series.is_empty() {
|
||||||
|
return (0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if stacked {
|
||||||
|
let n = data.categories.len();
|
||||||
|
let mut max_stack = 0.0_f64;
|
||||||
|
for ci in 0..n {
|
||||||
|
let sum: f64 = data
|
||||||
|
.series
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.values.get(ci).copied().unwrap_or(0.0))
|
||||||
|
.sum();
|
||||||
|
max_stack = max_stack.max(sum);
|
||||||
|
}
|
||||||
|
(0.0, max_stack * 1.05)
|
||||||
|
} else {
|
||||||
|
let mut min_v = f64::MAX;
|
||||||
|
let mut max_v = f64::MIN;
|
||||||
|
for series in &data.series {
|
||||||
|
for val in &series.values {
|
||||||
|
min_v = min_v.min(*val);
|
||||||
|
max_v = max_v.max(*val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// min sifirdan buyukse sifirdan basla
|
||||||
|
if min_v > 0.0 {
|
||||||
|
min_v = 0.0;
|
||||||
|
}
|
||||||
|
max_v *= 1.05;
|
||||||
|
(min_v, max_v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_value(v: f64) -> String {
|
||||||
|
if v.abs() >= 1_000_000.0 {
|
||||||
|
format!("{:.1}M", v / 1_000_000.0)
|
||||||
|
} else if v.abs() >= 1_000.0 {
|
||||||
|
format!("{:.1}K", v / 1_000.0)
|
||||||
|
} else if v.fract().abs() < 1e-10 {
|
||||||
|
format!("{}", v as i64)
|
||||||
|
} else {
|
||||||
|
format!("{:.1}", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_xml(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
@@ -68,6 +68,26 @@ pub struct ResolvedRichSpan {
|
|||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Çözümlenmiş chart verisi
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResolvedChartData {
|
||||||
|
pub chart_type: ChartType,
|
||||||
|
pub categories: Vec<String>,
|
||||||
|
pub series: Vec<ChartSeries>,
|
||||||
|
pub title: Option<ChartTitle>,
|
||||||
|
pub legend: Option<ChartLegend>,
|
||||||
|
pub labels: Option<ChartLabels>,
|
||||||
|
pub axis: Option<ChartAxis>,
|
||||||
|
pub style: ChartStyle,
|
||||||
|
pub group_mode: Option<GroupMode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChartSeries {
|
||||||
|
pub name: String,
|
||||||
|
pub values: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Her element ID'si için çözümlenmiş text içeriğini tutar.
|
/// Her element ID'si için çözümlenmiş text içeriğini tutar.
|
||||||
/// Table ve barcode gibi özel tipler de burada çözülür.
|
/// Table ve barcode gibi özel tipler de burada çözülür.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -84,6 +104,8 @@ pub struct ResolvedData {
|
|||||||
pub page_number_formats: HashMap<String, String>,
|
pub page_number_formats: HashMap<String, String>,
|
||||||
/// element_id → çözümlenmiş rich text span listesi
|
/// element_id → çözümlenmiş rich text span listesi
|
||||||
pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>,
|
pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>,
|
||||||
|
/// element_id → çözümlenmiş chart verisi
|
||||||
|
pub charts: HashMap<String, ResolvedChartData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -123,6 +145,7 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
|
|||||||
images: HashMap::new(),
|
images: HashMap::new(),
|
||||||
page_number_formats: HashMap::new(),
|
page_number_formats: HashMap::new(),
|
||||||
rich_texts: HashMap::new(),
|
rich_texts: HashMap::new(),
|
||||||
|
charts: HashMap::new(),
|
||||||
};
|
};
|
||||||
if let Some(ref header) = template.header {
|
if let Some(ref header) = template.header {
|
||||||
resolve_element(&TemplateElement::Container(header.clone()), data, &mut resolved);
|
resolve_element(&TemplateElement::Container(header.clone()), data, &mut resolved);
|
||||||
@@ -248,12 +271,110 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
|||||||
.collect();
|
.collect();
|
||||||
resolved.rich_texts.insert(e.id.clone(), spans);
|
resolved.rich_texts.insert(e.id.clone(), spans);
|
||||||
}
|
}
|
||||||
|
TemplateElement::Chart(e) => {
|
||||||
|
let array = resolve_path(data, &e.data_source.path);
|
||||||
|
let chart_data = match array {
|
||||||
|
Value::Array(items) if !items.is_empty() => {
|
||||||
|
resolve_chart_data(e, items)
|
||||||
|
}
|
||||||
|
_ => ResolvedChartData {
|
||||||
|
chart_type: e.chart_type.clone(),
|
||||||
|
categories: vec![],
|
||||||
|
series: vec![],
|
||||||
|
title: e.title.clone(),
|
||||||
|
legend: e.legend.clone(),
|
||||||
|
labels: e.labels.clone(),
|
||||||
|
axis: e.axis.clone(),
|
||||||
|
style: e.style.clone(),
|
||||||
|
group_mode: e.group_mode.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
resolved.charts.insert(e.id.clone(), chart_data);
|
||||||
|
}
|
||||||
TemplateElement::Line(_) => {}
|
TemplateElement::Line(_) => {}
|
||||||
TemplateElement::Shape(_) => {}
|
TemplateElement::Shape(_) => {}
|
||||||
TemplateElement::PageBreak(_) => {}
|
TemplateElement::PageBreak(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_chart_data(e: &ChartElement, items: &[Value]) -> ResolvedChartData {
|
||||||
|
let (categories, series) = if let Some(ref group_field) = e.group_field {
|
||||||
|
// Grouped: her distinct group değeri bir seri olur
|
||||||
|
let mut category_order: Vec<String> = Vec::new();
|
||||||
|
let mut category_set = std::collections::HashSet::new();
|
||||||
|
let mut group_order: Vec<String> = Vec::new();
|
||||||
|
let mut group_set = std::collections::HashSet::new();
|
||||||
|
// group_name → (category → value) (birden fazla aynı group+category olursa topla)
|
||||||
|
let mut group_data: HashMap<String, HashMap<String, f64>> = HashMap::new();
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
let cat = value_to_string(resolve_path(item, &e.category_field));
|
||||||
|
let val = resolve_path(item, &e.value_field)
|
||||||
|
.as_f64()
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let grp = value_to_string(resolve_path(item, group_field));
|
||||||
|
|
||||||
|
if category_set.insert(cat.clone()) {
|
||||||
|
category_order.push(cat.clone());
|
||||||
|
}
|
||||||
|
if group_set.insert(grp.clone()) {
|
||||||
|
group_order.push(grp.clone());
|
||||||
|
}
|
||||||
|
*group_data
|
||||||
|
.entry(grp)
|
||||||
|
.or_default()
|
||||||
|
.entry(cat)
|
||||||
|
.or_insert(0.0) += val;
|
||||||
|
}
|
||||||
|
|
||||||
|
let series = group_order
|
||||||
|
.iter()
|
||||||
|
.map(|grp| {
|
||||||
|
let grp_map = group_data.get(grp).unwrap();
|
||||||
|
let values = category_order
|
||||||
|
.iter()
|
||||||
|
.map(|cat| *grp_map.get(cat).unwrap_or(&0.0))
|
||||||
|
.collect();
|
||||||
|
ChartSeries {
|
||||||
|
name: grp.clone(),
|
||||||
|
values,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(category_order, series)
|
||||||
|
} else {
|
||||||
|
// Tek seri
|
||||||
|
let mut categories = Vec::new();
|
||||||
|
let mut values = Vec::new();
|
||||||
|
for item in items {
|
||||||
|
categories.push(value_to_string(resolve_path(item, &e.category_field)));
|
||||||
|
values.push(
|
||||||
|
resolve_path(item, &e.value_field)
|
||||||
|
.as_f64()
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let series = vec![ChartSeries {
|
||||||
|
name: e.value_field.clone(),
|
||||||
|
values,
|
||||||
|
}];
|
||||||
|
(categories, series)
|
||||||
|
};
|
||||||
|
|
||||||
|
ResolvedChartData {
|
||||||
|
chart_type: e.chart_type.clone(),
|
||||||
|
categories,
|
||||||
|
series,
|
||||||
|
title: e.title.clone(),
|
||||||
|
legend: e.legend.clone(),
|
||||||
|
labels: e.labels.clone(),
|
||||||
|
axis: e.axis.clone(),
|
||||||
|
style: e.style.clone(),
|
||||||
|
group_mode: e.group_mode.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ pub mod expr_eval;
|
|||||||
pub mod wasm_api;
|
pub mod wasm_api;
|
||||||
|
|
||||||
pub mod barcode_gen;
|
pub mod barcode_gen;
|
||||||
|
pub mod chart_render;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod pdf_render;
|
pub mod pdf_render;
|
||||||
|
|
||||||
use dreport_core::models::Template;
|
use dreport_core::models::{ChartType, Template};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// --- Layout sonuç tipleri ---
|
// --- Layout sonuç tipleri ---
|
||||||
@@ -73,6 +74,56 @@ pub enum ResolvedContent {
|
|||||||
rows: Vec<Vec<TableCell>>,
|
rows: Vec<Vec<TableCell>>,
|
||||||
column_widths_mm: Vec<f64>,
|
column_widths_mm: Vec<f64>,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "chart")]
|
||||||
|
Chart {
|
||||||
|
svg: String,
|
||||||
|
/// PDF render icin chart verisi (frontend bunu kullanmaz)
|
||||||
|
#[serde(flatten)]
|
||||||
|
chart_data: ChartRenderData,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PDF renderer icin chart verisi — ResolvedContent::Chart icinde tasınır
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChartRenderData {
|
||||||
|
pub chart_type: ChartType,
|
||||||
|
pub categories: Vec<String>,
|
||||||
|
pub series: Vec<ChartSeriesData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title_text: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title_font_size: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub colors: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub show_labels: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label_font_size: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub show_grid: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub grid_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bar_gap: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stacked: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub inner_radius: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub show_points: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub line_width: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub background_color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChartSeriesData {
|
||||||
|
pub name: String,
|
||||||
|
pub values: Vec<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ fn mm(v: f64) -> f32 {
|
|||||||
v as f32 * MM_TO_PT
|
v as f32 * MM_TO_PT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// f64 mm degerini f32 pt'ye cevir (chart render icin)
|
||||||
|
fn pt(mm_val: f64) -> f32 {
|
||||||
|
mm_val as f32 * MM_TO_PT
|
||||||
|
}
|
||||||
|
|
||||||
/// Hex renk (#RRGGBB veya #RGB) → rgb::Color
|
/// Hex renk (#RRGGBB veya #RGB) → rgb::Color
|
||||||
fn parse_color(hex: &str) -> rgb::Color {
|
fn parse_color(hex: &str) -> rgb::Color {
|
||||||
let hex = hex.trim_start_matches('#');
|
let hex = hex.trim_start_matches('#');
|
||||||
@@ -248,6 +253,9 @@ fn render_element(
|
|||||||
ResolvedContent::RichText { spans } => {
|
ResolvedContent::RichText { spans } => {
|
||||||
render_rich_text(surface, x, y, w, h, spans, &el.style, fonts, measurer);
|
render_rich_text(surface, x, y, w, h, spans, &el.style, fonts, measurer);
|
||||||
}
|
}
|
||||||
|
ResolvedContent::Chart { chart_data, .. } => {
|
||||||
|
render_chart(surface, x, y, w, h, chart_data, fonts, measurer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,6 +748,388 @@ fn embed_png(
|
|||||||
surface.pop();
|
surface.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_chart(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
h: f32,
|
||||||
|
data: &crate::ChartRenderData,
|
||||||
|
fonts: &FontCollection,
|
||||||
|
measurer: &mut TextMeasurer,
|
||||||
|
) {
|
||||||
|
// Tum hesaplar mm cinsinden yapilir, cizim pt'ye cevrilir
|
||||||
|
// base_x_mm, base_y_mm: element'in sayfa uzerindeki mm pozisyonu
|
||||||
|
let base_x_mm: f64 = (x / MM_TO_PT) as f64;
|
||||||
|
let base_y_mm: f64 = (y / MM_TO_PT) as f64;
|
||||||
|
let w_mm: f64 = (w / MM_TO_PT) as f64;
|
||||||
|
let h_mm: f64 = (h / MM_TO_PT) as f64;
|
||||||
|
|
||||||
|
// Background
|
||||||
|
chart_rect(surface, base_x_mm, base_y_mm, w_mm, h_mm,
|
||||||
|
parse_color(data.background_color.as_deref().unwrap_or("#FFFFFF")));
|
||||||
|
|
||||||
|
// Margin'ler (SVG renderer ile ayni mantik)
|
||||||
|
let mut mt = 2.0_f64;
|
||||||
|
let mut mb = 4.0_f64;
|
||||||
|
let ml = 14.0_f64;
|
||||||
|
let mr = 4.0_f64;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
if let Some(ref title) = data.title_text {
|
||||||
|
if !title.is_empty() {
|
||||||
|
let fs = data.title_font_size.unwrap_or(4.0);
|
||||||
|
mt += fs * 0.4 + 2.0;
|
||||||
|
let color = parse_color(data.title_color.as_deref().unwrap_or("#333333"));
|
||||||
|
let font = fonts.get(None, Some("bold"));
|
||||||
|
if let Some(f) = font {
|
||||||
|
surface.set_fill(Some(fill_from_color(color)));
|
||||||
|
surface.set_stroke(None);
|
||||||
|
let fs_pt = fs as f32;
|
||||||
|
let (tw, _) = measurer.measure(title, None, fs_pt, Some("bold"), None);
|
||||||
|
let tx = pt(base_x_mm + w_mm / 2.0) - tw / 2.0;
|
||||||
|
let ty = pt(base_y_mm + mt - 1.0);
|
||||||
|
surface.draw_text(
|
||||||
|
Point::from_xy(tx, ty),
|
||||||
|
f.clone(), fs_pt, title, false, TextDirection::Auto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_pie = matches!(data.chart_type, dreport_core::models::ChartType::Pie);
|
||||||
|
|
||||||
|
if !is_pie {
|
||||||
|
let max_label_len = data.categories.iter().map(|c| c.len()).max().unwrap_or(0);
|
||||||
|
if max_label_len > 6 { mb += 10.0; } else { mb += 4.0; }
|
||||||
|
mb += 4.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plot_x = base_x_mm + ml;
|
||||||
|
let plot_y = base_y_mm + mt;
|
||||||
|
let plot_w = (w_mm - ml - mr).max(1.0);
|
||||||
|
let plot_h = (h_mm - mt - mb).max(1.0);
|
||||||
|
|
||||||
|
use dreport_core::models::ChartType;
|
||||||
|
match data.chart_type {
|
||||||
|
ChartType::Bar => render_chart_bar(surface, data, plot_x, plot_y, plot_w, plot_h),
|
||||||
|
ChartType::Line => render_chart_line(surface, data, plot_x, plot_y, plot_w, plot_h),
|
||||||
|
ChartType::Pie => render_chart_pie(surface, data, plot_x, plot_y, plot_w, plot_h),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mm degerlerini pt'ye cevirip rect ciz
|
||||||
|
fn chart_rect(surface: &mut krilla::surface::Surface<'_>, rx: f64, ry: f64, rw: f64, rh: f64, color: rgb::Color) {
|
||||||
|
let (rx, ry, rw, rh) = (pt(rx), pt(ry), pt(rw), pt(rh));
|
||||||
|
surface.set_fill(Some(fill_from_color(color)));
|
||||||
|
surface.set_stroke(None);
|
||||||
|
let path = {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
if let Some(r) = krilla::geom::Rect::from_xywh(rx, ry, rw, rh) {
|
||||||
|
pb.push_rect(r);
|
||||||
|
}
|
||||||
|
pb.finish()
|
||||||
|
};
|
||||||
|
if let Some(p) = path {
|
||||||
|
surface.draw_path(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chart_line_seg(surface: &mut krilla::surface::Surface<'_>, x1: f64, y1: f64, x2: f64, y2: f64, color: rgb::Color, width: f32) {
|
||||||
|
let (x1, y1, x2, y2) = (pt(x1), pt(y1), pt(x2), pt(y2));
|
||||||
|
surface.set_fill(None);
|
||||||
|
surface.set_stroke(Some(Stroke {
|
||||||
|
paint: color.into(),
|
||||||
|
width,
|
||||||
|
opacity: NormalizedF32::ONE,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
let path = {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.move_to(x1, y1);
|
||||||
|
pb.line_to(x2, y2);
|
||||||
|
pb.finish()
|
||||||
|
};
|
||||||
|
if let Some(p) = path {
|
||||||
|
surface.draw_path(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bar chart — tum koordinatlar mm cinsinden (mutlak sayfa pozisyonu)
|
||||||
|
fn render_chart_bar(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
data: &crate::ChartRenderData,
|
||||||
|
px: f64, py: f64, pw: f64, ph: f64,
|
||||||
|
) {
|
||||||
|
if data.categories.is_empty() || data.series.is_empty() { return; }
|
||||||
|
|
||||||
|
let (min_val, max_val) = chart_value_range(data);
|
||||||
|
let range = if (max_val - min_val).abs() < 1e-10 { 1.0 } else { max_val - min_val };
|
||||||
|
|
||||||
|
let n_cats = data.categories.len();
|
||||||
|
let n_series = data.series.len();
|
||||||
|
let cat_width = pw / n_cats as f64;
|
||||||
|
let bar_gap = data.bar_gap.unwrap_or(0.2).clamp(0.0, 0.8);
|
||||||
|
let group_width = cat_width * (1.0 - bar_gap);
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
if data.show_grid {
|
||||||
|
let gc = parse_color(data.grid_color.as_deref().unwrap_or("#E5E7EB"));
|
||||||
|
for i in 0..=5 {
|
||||||
|
let frac = i as f64 / 5.0;
|
||||||
|
let gy = py + ph - frac * ph;
|
||||||
|
chart_line_seg(surface, px, gy, px + pw, gy, gc, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis lines
|
||||||
|
let ac = parse_color("#9CA3AF");
|
||||||
|
chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8);
|
||||||
|
chart_line_seg(surface, px, py, px, py + ph, ac, 0.8);
|
||||||
|
|
||||||
|
// Bars
|
||||||
|
if data.stacked {
|
||||||
|
for ci in 0..n_cats {
|
||||||
|
let mut y_off = 0.0_f64;
|
||||||
|
for (si, series) in data.series.iter().enumerate() {
|
||||||
|
let val = series.values.get(ci).copied().unwrap_or(0.0);
|
||||||
|
let bh = (val / range) * ph;
|
||||||
|
let by = py + ph - y_off - bh;
|
||||||
|
let bx = px + ci as f64 * cat_width + cat_width * bar_gap / 2.0;
|
||||||
|
let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5"));
|
||||||
|
chart_rect(surface, bx, by, group_width, bh.max(0.0), color);
|
||||||
|
y_off += bh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let bar_w = group_width / n_series as f64;
|
||||||
|
for ci in 0..n_cats {
|
||||||
|
for (si, series) in data.series.iter().enumerate() {
|
||||||
|
let val = series.values.get(ci).copied().unwrap_or(0.0);
|
||||||
|
let bh = ((val - min_val) / range) * ph;
|
||||||
|
let bx = px + ci as f64 * cat_width + cat_width * bar_gap / 2.0 + si as f64 * bar_w;
|
||||||
|
let by = py + ph - bh;
|
||||||
|
let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5"));
|
||||||
|
chart_rect(surface, bx, by, bar_w.max(0.1), bh.max(0.0), color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Line chart — tum koordinatlar mm cinsinden (mutlak sayfa pozisyonu)
|
||||||
|
fn render_chart_line(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
data: &crate::ChartRenderData,
|
||||||
|
px: f64, py: f64, pw: f64, ph: f64,
|
||||||
|
) {
|
||||||
|
if data.categories.is_empty() || data.series.is_empty() { return; }
|
||||||
|
|
||||||
|
let (min_val, max_val) = chart_value_range(data);
|
||||||
|
let range = if (max_val - min_val).abs() < 1e-10 { 1.0 } else { max_val - min_val };
|
||||||
|
let n_cats = data.categories.len();
|
||||||
|
let line_w = data.line_width.unwrap_or(0.5);
|
||||||
|
let show_points = data.show_points.unwrap_or(true);
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
if data.show_grid {
|
||||||
|
let gc = parse_color(data.grid_color.as_deref().unwrap_or("#E5E7EB"));
|
||||||
|
for i in 0..=5 {
|
||||||
|
let frac = i as f64 / 5.0;
|
||||||
|
let gy = py + ph - frac * ph;
|
||||||
|
chart_line_seg(surface, px, gy, px + pw, gy, gc, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis
|
||||||
|
let ac = parse_color("#9CA3AF");
|
||||||
|
chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8);
|
||||||
|
|
||||||
|
for (si, series) in data.series.iter().enumerate() {
|
||||||
|
let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5"));
|
||||||
|
|
||||||
|
let points: Vec<(f64, f64)> = series.values.iter().enumerate().map(|(ci, val)| {
|
||||||
|
let xp = if n_cats == 1 { px + pw / 2.0 } else { px + ci as f64 * pw / (n_cats - 1) as f64 };
|
||||||
|
let yp = py + ph - ((val - min_val) / range) * ph;
|
||||||
|
(xp, yp)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Polyline
|
||||||
|
surface.set_fill(None);
|
||||||
|
surface.set_stroke(Some(Stroke {
|
||||||
|
paint: color.into(),
|
||||||
|
width: pt(line_w),
|
||||||
|
opacity: NormalizedF32::ONE,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
let path = {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
for (i, (lx, ly)) in points.iter().enumerate() {
|
||||||
|
if i == 0 { pb.move_to(pt(*lx), pt(*ly)); }
|
||||||
|
else { pb.line_to(pt(*lx), pt(*ly)); }
|
||||||
|
}
|
||||||
|
pb.finish()
|
||||||
|
};
|
||||||
|
if let Some(p) = path { surface.draw_path(&p); }
|
||||||
|
|
||||||
|
// Points
|
||||||
|
if show_points {
|
||||||
|
for (lx, ly) in &points {
|
||||||
|
let r = pt(0.8);
|
||||||
|
let cx = pt(*lx);
|
||||||
|
let cy = pt(*ly);
|
||||||
|
surface.set_fill(Some(fill_from_color(color)));
|
||||||
|
surface.set_stroke(None);
|
||||||
|
let circle = {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
let k = r * 0.5522848;
|
||||||
|
pb.move_to(cx, cy - r);
|
||||||
|
pb.cubic_to(cx + k, cy - r, cx + r, cy - k, cx + r, cy);
|
||||||
|
pb.cubic_to(cx + r, cy + k, cx + k, cy + r, cx, cy + r);
|
||||||
|
pb.cubic_to(cx - k, cy + r, cx - r, cy + k, cx - r, cy);
|
||||||
|
pb.cubic_to(cx - r, cy - k, cx - k, cy - r, cx, cy - r);
|
||||||
|
pb.close();
|
||||||
|
pb.finish()
|
||||||
|
};
|
||||||
|
if let Some(p) = circle { surface.draw_path(&p); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pie/donut chart — tum koordinatlar mm cinsinden
|
||||||
|
fn render_chart_pie(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
data: &crate::ChartRenderData,
|
||||||
|
px: f64, py: f64, pw: f64, ph: f64,
|
||||||
|
) {
|
||||||
|
let values: Vec<f64> = if data.series.len() == 1 {
|
||||||
|
data.series[0].values.clone()
|
||||||
|
} else {
|
||||||
|
data.categories.iter().enumerate().map(|(ci, _)| {
|
||||||
|
data.series.iter().map(|s| s.values.get(ci).copied().unwrap_or(0.0)).sum()
|
||||||
|
}).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let total: f64 = values.iter().sum();
|
||||||
|
if total <= 0.0 { return; }
|
||||||
|
|
||||||
|
let cx = px + pw / 2.0;
|
||||||
|
let cy = py + ph / 2.0;
|
||||||
|
let radius = pw.min(ph) / 2.0 * 0.9;
|
||||||
|
let inner_frac = data.inner_radius.unwrap_or(0.0).clamp(0.0, 0.9);
|
||||||
|
let inner_r = radius * inner_frac;
|
||||||
|
|
||||||
|
let mut start_angle = -std::f64::consts::FRAC_PI_2;
|
||||||
|
|
||||||
|
for (i, val) in values.iter().enumerate() {
|
||||||
|
if *val <= 0.0 { continue; }
|
||||||
|
let sweep = (val / total) * std::f64::consts::TAU;
|
||||||
|
let end_angle = start_angle + sweep;
|
||||||
|
let color = parse_color(data.colors.get(i).map(|s| s.as_str()).unwrap_or("#4F46E5"));
|
||||||
|
|
||||||
|
surface.set_fill(Some(fill_from_color(color)));
|
||||||
|
surface.set_stroke(Some(Stroke {
|
||||||
|
paint: rgb::Color::new(255, 255, 255).into(),
|
||||||
|
width: 0.8,
|
||||||
|
opacity: NormalizedF32::ONE,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let path = build_arc_path(cx, cy, radius, inner_r, start_angle, end_angle);
|
||||||
|
if let Some(p) = path { surface.draw_path(&p); }
|
||||||
|
|
||||||
|
start_angle = end_angle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arc path olustur — pie/donut dilimi (mm cinsinden, pt'ye cevrilir)
|
||||||
|
fn build_arc_path(
|
||||||
|
cx: f64, cy: f64,
|
||||||
|
radius: f64, inner_r: f64,
|
||||||
|
start: f64, end: f64,
|
||||||
|
) -> Option<krilla::geom::Path> {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
|
||||||
|
let sx = pt(cx + radius * start.cos());
|
||||||
|
let sy = pt(cy + radius * start.sin());
|
||||||
|
|
||||||
|
if inner_r > 0.0 {
|
||||||
|
pb.move_to(sx, sy);
|
||||||
|
approximate_arc(&mut pb, cx, cy, radius, start, end);
|
||||||
|
let ix = pt(cx + inner_r * end.cos());
|
||||||
|
let iy = pt(cy + inner_r * end.sin());
|
||||||
|
pb.line_to(ix, iy);
|
||||||
|
approximate_arc(&mut pb, cx, cy, inner_r, end, start);
|
||||||
|
pb.close();
|
||||||
|
} else {
|
||||||
|
pb.move_to(pt(cx), pt(cy));
|
||||||
|
pb.line_to(sx, sy);
|
||||||
|
approximate_arc(&mut pb, cx, cy, radius, start, end);
|
||||||
|
pb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arc'i cubic bezier segmentleriyle yaklasik ciz (her segment ≤ 90°)
|
||||||
|
fn approximate_arc(
|
||||||
|
pb: &mut PathBuilder,
|
||||||
|
cx: f64, cy: f64,
|
||||||
|
r: f64,
|
||||||
|
start: f64, end: f64,
|
||||||
|
) {
|
||||||
|
let sweep = end - start;
|
||||||
|
let n_segs = ((sweep.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1);
|
||||||
|
let seg_sweep = sweep / n_segs as f64;
|
||||||
|
|
||||||
|
for i in 0..n_segs {
|
||||||
|
let a1 = start + i as f64 * seg_sweep;
|
||||||
|
let a2 = a1 + seg_sweep;
|
||||||
|
let alpha = seg_sweep / 2.0;
|
||||||
|
let cos_a = alpha.cos();
|
||||||
|
let k = (4.0 / 3.0) * (1.0 - cos_a) / alpha.sin();
|
||||||
|
|
||||||
|
let p2x = cx + r * a2.cos();
|
||||||
|
let p2y = cy + r * a2.sin();
|
||||||
|
let p1x = cx + r * a1.cos();
|
||||||
|
let p1y = cy + r * a1.sin();
|
||||||
|
|
||||||
|
let c1x = p1x - k * r * a1.sin();
|
||||||
|
let c1y = p1y + k * r * a1.cos();
|
||||||
|
let c2x = p2x + k * r * a2.sin();
|
||||||
|
let c2y = p2y - k * r * a2.cos();
|
||||||
|
|
||||||
|
pb.cubic_to(pt(c1x), pt(c1y), pt(c2x), pt(c2y), pt(p2x), pt(p2y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chart_value_range(data: &crate::ChartRenderData) -> (f64, f64) {
|
||||||
|
if data.series.is_empty() {
|
||||||
|
return (0.0, 1.0);
|
||||||
|
}
|
||||||
|
if data.stacked {
|
||||||
|
let n = data.categories.len();
|
||||||
|
let mut max_stack = 0.0_f64;
|
||||||
|
for ci in 0..n {
|
||||||
|
let sum: f64 = data.series.iter().map(|s| s.values.get(ci).copied().unwrap_or(0.0)).sum();
|
||||||
|
max_stack = max_stack.max(sum);
|
||||||
|
}
|
||||||
|
(0.0, max_stack * 1.05)
|
||||||
|
} else {
|
||||||
|
let mut min_v = f64::MAX;
|
||||||
|
let mut max_v = f64::MIN;
|
||||||
|
for series in &data.series {
|
||||||
|
for val in &series.values {
|
||||||
|
min_v = min_v.min(*val);
|
||||||
|
max_v = max_v.max(*val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if min_v > 0.0 { min_v = 0.0; }
|
||||||
|
max_v *= 1.05;
|
||||||
|
(min_v, max_v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ mod tests {
|
|||||||
images: HashMap::new(),
|
images: HashMap::new(),
|
||||||
page_number_formats: HashMap::new(),
|
page_number_formats: HashMap::new(),
|
||||||
rich_texts: HashMap::new(),
|
rich_texts: HashMap::new(),
|
||||||
|
charts: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ pub fn compute(
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let body_elements = collect_layout(&taffy, root_node, &node_map, 0.0, 0.0);
|
let body_elements = collect_layout(&taffy, root_node, &node_map, resolved, 0.0, 0.0);
|
||||||
|
|
||||||
// --- 4. Container break modlarını topla ---
|
// --- 4. Container break modlarını topla ---
|
||||||
let break_modes = collect_break_modes(&template.root);
|
let break_modes = collect_break_modes(&template.root);
|
||||||
@@ -155,7 +155,7 @@ fn compute_section(
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let elements = collect_layout(&taffy, section_node, &node_map, 0.0, 0.0);
|
let elements = collect_layout(&taffy, section_node, &node_map, resolved, 0.0, 0.0);
|
||||||
|
|
||||||
// Section yüksekliği
|
// Section yüksekliği
|
||||||
let section_layout = taffy.layout(section_node).unwrap();
|
let section_layout = taffy.layout(section_node).unwrap();
|
||||||
@@ -553,6 +553,28 @@ fn build_element(
|
|||||||
);
|
);
|
||||||
node
|
node
|
||||||
}
|
}
|
||||||
|
TemplateElement::Chart(e) => {
|
||||||
|
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||||
|
// Default minimum boyut — Auto ise chart cok kucuk olmasin
|
||||||
|
if matches!(e.size.width, SizeValue::Auto) {
|
||||||
|
style.min_size.width = Dimension::length(mm_to_pt(80.0));
|
||||||
|
}
|
||||||
|
if matches!(e.size.height, SizeValue::Auto) {
|
||||||
|
style.min_size.height = Dimension::length(mm_to_pt(60.0));
|
||||||
|
}
|
||||||
|
let node = taffy.new_leaf(style).unwrap();
|
||||||
|
node_map.insert(
|
||||||
|
node,
|
||||||
|
NodeInfo {
|
||||||
|
element_id: e.id.clone(),
|
||||||
|
element_type: "chart".to_string(),
|
||||||
|
content: None, // SVG collect_layout'ta uretilecek
|
||||||
|
style: ResolvedStyle::default(),
|
||||||
|
children_ids: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
node
|
||||||
|
}
|
||||||
TemplateElement::PageBreak(e) => {
|
TemplateElement::PageBreak(e) => {
|
||||||
// Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt)
|
// Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt)
|
||||||
let style = Style {
|
let style = Style {
|
||||||
@@ -694,6 +716,7 @@ fn collect_layout(
|
|||||||
taffy: &TaffyTree<MeasureContext>,
|
taffy: &TaffyTree<MeasureContext>,
|
||||||
node: NodeId,
|
node: NodeId,
|
||||||
node_map: &HashMap<NodeId, NodeInfo>,
|
node_map: &HashMap<NodeId, NodeInfo>,
|
||||||
|
resolved: &ResolvedData,
|
||||||
parent_x_mm: f64,
|
parent_x_mm: f64,
|
||||||
parent_y_mm: f64,
|
parent_y_mm: f64,
|
||||||
) -> Vec<ElementLayout> {
|
) -> Vec<ElementLayout> {
|
||||||
@@ -709,6 +732,52 @@ fn collect_layout(
|
|||||||
let w_mm = pt_to_mm(layout.size.width);
|
let w_mm = pt_to_mm(layout.size.width);
|
||||||
let h_mm = pt_to_mm(layout.size.height);
|
let h_mm = pt_to_mm(layout.size.height);
|
||||||
|
|
||||||
|
// Chart elementleri icin SVG uret (boyutlar artik belli)
|
||||||
|
let content = if info.element_type == "chart" {
|
||||||
|
resolved.charts.get(&info.element_id).map(|cd| {
|
||||||
|
use crate::{ChartRenderData, ChartSeriesData};
|
||||||
|
use crate::chart_render::DEFAULT_COLORS;
|
||||||
|
|
||||||
|
// Renk paleti olustur
|
||||||
|
let n_colors = cd.categories.len().max(cd.series.len()).max(1);
|
||||||
|
let colors: Vec<String> = (0..n_colors)
|
||||||
|
.map(|i| {
|
||||||
|
cd.style.colors.as_ref()
|
||||||
|
.and_then(|c| c.get(i).cloned())
|
||||||
|
.unwrap_or_else(|| DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ResolvedContent::Chart {
|
||||||
|
svg: crate::chart_render::render_svg(cd, w_mm, h_mm),
|
||||||
|
chart_data: ChartRenderData {
|
||||||
|
chart_type: cd.chart_type.clone(),
|
||||||
|
categories: cd.categories.clone(),
|
||||||
|
series: cd.series.iter().map(|s| ChartSeriesData {
|
||||||
|
name: s.name.clone(),
|
||||||
|
values: s.values.clone(),
|
||||||
|
}).collect(),
|
||||||
|
title_text: cd.title.as_ref().map(|t| t.text.clone()),
|
||||||
|
title_font_size: cd.title.as_ref().and_then(|t| t.font_size),
|
||||||
|
title_color: cd.title.as_ref().and_then(|t| t.color.clone()),
|
||||||
|
colors,
|
||||||
|
show_labels: cd.labels.as_ref().is_some_and(|l| l.show),
|
||||||
|
label_font_size: cd.labels.as_ref().and_then(|l| l.font_size),
|
||||||
|
show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true),
|
||||||
|
grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()),
|
||||||
|
bar_gap: cd.style.bar_gap,
|
||||||
|
stacked: matches!(cd.group_mode, Some(dreport_core::models::GroupMode::Stacked)),
|
||||||
|
inner_radius: cd.style.inner_radius,
|
||||||
|
show_points: cd.style.show_points,
|
||||||
|
line_width: cd.style.line_width,
|
||||||
|
background_color: cd.style.background_color.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
info.content.clone()
|
||||||
|
};
|
||||||
|
|
||||||
elements.push(ElementLayout {
|
elements.push(ElementLayout {
|
||||||
id: info.element_id.clone(),
|
id: info.element_id.clone(),
|
||||||
x_mm,
|
x_mm,
|
||||||
@@ -716,7 +785,7 @@ fn collect_layout(
|
|||||||
width_mm: w_mm,
|
width_mm: w_mm,
|
||||||
height_mm: h_mm,
|
height_mm: h_mm,
|
||||||
element_type: info.element_type.clone(),
|
element_type: info.element_type.clone(),
|
||||||
content: info.content.clone(),
|
content,
|
||||||
style: info.style.clone(),
|
style: info.style.clone(),
|
||||||
children: info.children_ids.clone(),
|
children: info.children_ids.clone(),
|
||||||
});
|
});
|
||||||
@@ -724,7 +793,7 @@ fn collect_layout(
|
|||||||
// Child node'ları da topla
|
// Child node'ları da topla
|
||||||
let children = taffy.children(node).unwrap();
|
let children = taffy.children(node).unwrap();
|
||||||
for child_node in children {
|
for child_node in children {
|
||||||
let child_elements = collect_layout(taffy, child_node, node_map, x_mm, y_mm);
|
let child_elements = collect_layout(taffy, child_node, node_map, resolved, x_mm, y_mm);
|
||||||
elements.extend(child_elements);
|
elements.extend(child_elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user