mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
minimap & chart label angle
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -397,9 +397,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dexpr"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/"
|
||||
checksum = "37e0a98f2810bb770c76ef1e99d07066a15997086f9ead93917a82711274af25"
|
||||
checksum = "e65e74adffaab8b52681e3e3e5006365f0f8c5e3e07870cbd58ca74769eb150a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"indexmap",
|
||||
|
||||
@@ -129,45 +129,24 @@ const sampleData: Record<string, unknown> = {
|
||||
telefon: '+90 216 444 0018',
|
||||
},
|
||||
kalemler: [
|
||||
{
|
||||
siraNo: 1,
|
||||
adi: 'Web Uygulama Gelistirme',
|
||||
miktar: 1,
|
||||
birim: 'Adet',
|
||||
birimFiyat: 45000,
|
||||
tutar: 45000,
|
||||
},
|
||||
{
|
||||
siraNo: 2,
|
||||
adi: 'Mobil Uygulama Gelistirme',
|
||||
miktar: 1,
|
||||
birim: 'Adet',
|
||||
birimFiyat: 35000,
|
||||
tutar: 35000,
|
||||
},
|
||||
{
|
||||
siraNo: 3,
|
||||
adi: 'UI/UX Tasarim Hizmeti',
|
||||
miktar: 40,
|
||||
birim: 'Saat',
|
||||
birimFiyat: 750,
|
||||
tutar: 30000,
|
||||
},
|
||||
{
|
||||
siraNo: 4,
|
||||
adi: 'Sunucu Bakim Sozlesmesi (Yillik)',
|
||||
miktar: 1,
|
||||
birim: 'Adet',
|
||||
birimFiyat: 12000,
|
||||
tutar: 12000,
|
||||
},
|
||||
{ siraNo: 1, adi: 'Web Uygulama Gelistirme', miktar: 1, birim: 'Adet', birimFiyat: 45000, tutar: 45000 },
|
||||
{ siraNo: 2, adi: 'Mobil Uygulama Gelistirme', miktar: 1, birim: 'Adet', birimFiyat: 35000, tutar: 35000 },
|
||||
{ siraNo: 3, adi: 'UI/UX Tasarim Hizmeti', miktar: 40, birim: 'Saat', birimFiyat: 750, tutar: 30000 },
|
||||
{ siraNo: 4, adi: 'Sunucu Bakim Sozlesmesi (Yillik)', miktar: 1, birim: 'Adet', birimFiyat: 12000, tutar: 12000 },
|
||||
{ siraNo: 5, adi: 'SSL Sertifikasi', miktar: 3, birim: 'Adet', birimFiyat: 500, tutar: 1500 },
|
||||
{ siraNo: 6, adi: 'Veritabani Yonetimi', miktar: 12, birim: 'Ay', birimFiyat: 2000, tutar: 24000 },
|
||||
{ siraNo: 7, adi: 'API Entegrasyon Hizmeti', miktar: 1, birim: 'Adet', birimFiyat: 18000, tutar: 18000 },
|
||||
{ siraNo: 8, adi: 'Bulut Altyapi Kurulumu', miktar: 1, birim: 'Adet', birimFiyat: 8000, tutar: 8000 },
|
||||
{ siraNo: 9, adi: 'Siber Guvenlik Danismanligi', miktar: 20, birim: 'Saat', birimFiyat: 900, tutar: 18000 },
|
||||
{ siraNo: 10, adi: 'E-posta Sunucu Yapilandirmasi', miktar: 1, birim: 'Adet', birimFiyat: 3500, tutar: 3500 },
|
||||
{ siraNo: 11, adi: 'Yedekleme Sistemi Kurulumu', miktar: 1, birim: 'Adet', birimFiyat: 5000, tutar: 5000 },
|
||||
{ siraNo: 12, adi: 'SEO Optimizasyonu', miktar: 1, birim: 'Adet', birimFiyat: 7500, tutar: 7500 },
|
||||
{ siraNo: 13, adi: 'Egitim ve Dokumantasyon', miktar: 8, birim: 'Saat', birimFiyat: 600, tutar: 4800 },
|
||||
{ siraNo: 14, adi: 'Performans Testi ve Raporlama', miktar: 1, birim: 'Adet', birimFiyat: 6000, tutar: 6000 },
|
||||
{ siraNo: 15, adi: 'Teknik Destek Paketi (6 Ay)', miktar: 1, birim: 'Adet', birimFiyat: 9000, tutar: 9000 },
|
||||
],
|
||||
toplamlar: {
|
||||
araToplam: 123500,
|
||||
kdvOrani: 20,
|
||||
kdv: 24700,
|
||||
genelToplam: 148200,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -480,22 +459,66 @@ const defaultInvoiceTemplate: Template = {
|
||||
style: { borderColor: '#e2e8f0', borderWidth: 0.5 },
|
||||
children: [
|
||||
{
|
||||
id: 'el_ara_toplam',
|
||||
type: 'text',
|
||||
id: 'c_ara_toplam_row',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||
content: 'Ara Toplam: ',
|
||||
binding: { type: 'scalar', path: 'toplamlar.araToplam' },
|
||||
size: { width: sz.fr(), height: sz.auto() },
|
||||
direction: 'row',
|
||||
gap: 2,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'center',
|
||||
justify: 'space-between',
|
||||
style: {},
|
||||
children: [
|
||||
{
|
||||
id: 'el_ara_toplam_label',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333' },
|
||||
content: 'Ara Toplam:',
|
||||
},
|
||||
{
|
||||
id: 'el_ara_toplam',
|
||||
type: 'calculated_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||
expression: 'kalemler.tutar.sum()',
|
||||
format: 'currency',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'el_kdv',
|
||||
type: 'text',
|
||||
id: 'c_kdv_row',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||
content: 'KDV (%20): ',
|
||||
binding: { type: 'scalar', path: 'toplamlar.kdv' },
|
||||
size: { width: sz.fr(), height: sz.auto() },
|
||||
direction: 'row',
|
||||
gap: 2,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'center',
|
||||
justify: 'space-between',
|
||||
style: {},
|
||||
children: [
|
||||
{
|
||||
id: 'el_kdv_label',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333' },
|
||||
content: 'KDV (%20):',
|
||||
},
|
||||
{
|
||||
id: 'el_kdv',
|
||||
type: 'calculated_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||
expression: 'kalemler.tutar.sum() * toplamlar.kdvOrani / 100',
|
||||
format: 'currency',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'el_cizgi_2',
|
||||
@@ -505,13 +528,35 @@ const defaultInvoiceTemplate: Template = {
|
||||
style: { strokeColor: '#1e293b', strokeWidth: 1 },
|
||||
},
|
||||
{
|
||||
id: 'el_genel_toplam',
|
||||
type: 'text',
|
||||
id: 'c_genel_toplam_row',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' },
|
||||
content: 'GENEL TOPLAM: ',
|
||||
binding: { type: 'scalar', path: 'toplamlar.genelToplam' },
|
||||
size: { width: sz.fr(), height: sz.auto() },
|
||||
direction: 'row',
|
||||
gap: 2,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'center',
|
||||
justify: 'space-between',
|
||||
style: {},
|
||||
children: [
|
||||
{
|
||||
id: 'el_genel_toplam_label',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a' },
|
||||
content: 'GENEL TOPLAM:',
|
||||
},
|
||||
{
|
||||
id: 'el_genel_toplam',
|
||||
type: 'calculated_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' },
|
||||
expression: 'kalemler.tutar.sum() * (1 + toplamlar.kdvOrani / 100)',
|
||||
format: 'currency',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLayoutEngine } from '../../composables/useLayoutEngine'
|
||||
import LayoutRenderer from './LayoutRenderer.vue'
|
||||
import InteractionOverlay from './InteractionOverlay.vue'
|
||||
import RulerBar from './RulerBar.vue'
|
||||
import MinimapOverlay from './MinimapOverlay.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -23,6 +24,7 @@ const { template, mockData, layoutVersion } = storeToRefs(templateStore)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const containerWidth = ref(800)
|
||||
const containerHeight = ref(600)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'compile-error': [error: string | null]
|
||||
@@ -73,6 +75,29 @@ const pagesContainerStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Pan sınırları
|
||||
// pan=0 → sayfa yatayda viewport ortasında, dikeyde üstte.
|
||||
// Kural: sayfanın en az yarısı viewport'ta görünsün.
|
||||
function clampPan(x: number, y: number): [number, number] {
|
||||
const pageW = templateStore.template.page.width * scale.value
|
||||
const pageCount = Math.max(1, layoutPages.value.length)
|
||||
const pageGap = 24
|
||||
const totalH = pageHeightPx.value * pageCount + pageGap * (pageCount - 1)
|
||||
|
||||
const viewH = (containerRef.value?.clientHeight ?? 600) - 60 - 40
|
||||
|
||||
// Yatay: pageLeft = (viewW - pageW)/2 + panX → sayfanın yarısı viewport'ta kalmalı
|
||||
const clampX = pageW / 2
|
||||
// Dikey: pageTop = panY → sayfanın yarısı viewport'ta kalmalı
|
||||
const maxY = viewH * 0.5
|
||||
const minY = viewH * 0.5 - totalH
|
||||
|
||||
return [
|
||||
Math.max(-clampX, Math.min(clampX, x)),
|
||||
Math.max(minY, Math.min(maxY, y)),
|
||||
]
|
||||
}
|
||||
|
||||
// Pan transform — sayfa container'ına uygulanacak
|
||||
const panTransform = computed(() => {
|
||||
if (editorStore.panX === 0 && editorStore.panY === 0) return undefined
|
||||
@@ -98,7 +123,10 @@ onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) containerWidth.value = entry.contentRect.width
|
||||
if (entry) {
|
||||
containerWidth.value = entry.contentRect.width
|
||||
containerHeight.value = entry.contentRect.height
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
@@ -142,7 +170,8 @@ function onWheel(e: WheelEvent) {
|
||||
} else {
|
||||
// İki parmak pan (touchpad) veya normal scroll
|
||||
e.preventDefault()
|
||||
editorStore.setPan(editorStore.panX - e.deltaX, editorStore.panY - e.deltaY)
|
||||
const [cx, cy] = clampPan(editorStore.panX - e.deltaX, editorStore.panY - e.deltaY)
|
||||
editorStore.setPan(cx, cy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +201,8 @@ function applyZoom(delta: number, clientX: number, clientY: number) {
|
||||
const newPanY = editorStore.panY + mousePageMmY * (oldScale - newScale)
|
||||
|
||||
editorStore.setZoom(newZoom)
|
||||
editorStore.setPan(newPanX, newPanY)
|
||||
const [cx, cy] = clampPan(newPanX, newPanY)
|
||||
editorStore.setPan(cx, cy)
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -208,7 +238,8 @@ function onPointerDown(e: PointerEvent) {
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isPanning.value) return
|
||||
editorStore.setPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
|
||||
const [cx2, cy2] = clampPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
|
||||
editorStore.setPan(cx2, cy2)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
@@ -217,6 +248,11 @@ function onPointerUp(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function onMinimapNavigate(x: number, y: number) {
|
||||
const [cx, cy] = clampPan(x, y)
|
||||
editorStore.setPan(cx, cy)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -228,6 +264,9 @@ function onPointerUp(e: PointerEvent) {
|
||||
:scale="scale"
|
||||
:pan-x="editorStore.panX"
|
||||
:pan-y="editorStore.panY"
|
||||
:container-width="containerWidth"
|
||||
:page-count="layoutPages.length"
|
||||
:page-gap="24"
|
||||
/>
|
||||
|
||||
<!-- Scroll alanı -->
|
||||
@@ -261,7 +300,24 @@ function onPointerUp(e: PointerEvent) {
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="compiling" class="editor-canvas__compiling">Derleniyor...</div>
|
||||
<div class="editor-canvas__zoom">%{{ editorStore.zoomPercent }}</div>
|
||||
|
||||
<!-- Minimap + zoom göstergesi -->
|
||||
<div class="editor-canvas__minimap-area">
|
||||
<MinimapOverlay
|
||||
:layout="layout"
|
||||
:page-width="templateStore.template.page.width"
|
||||
:page-height="templateStore.template.page.height"
|
||||
:zoom="editorStore.zoom"
|
||||
:pan-x="editorStore.panX"
|
||||
:pan-y="editorStore.panY"
|
||||
:container-width="containerWidth"
|
||||
:container-height="containerHeight"
|
||||
:scale="scale"
|
||||
:page-gap="24"
|
||||
@navigate="onMinimapNavigate"
|
||||
/>
|
||||
<div class="editor-canvas__zoom">%{{ editorStore.zoomPercent }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -318,15 +374,22 @@ function onPointerUp(e: PointerEvent) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.editor-canvas__zoom {
|
||||
.editor-canvas__minimap-area {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.editor-canvas__zoom {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
|
||||
442
frontend/src/components/editor/MinimapOverlay.vue
Normal file
442
frontend/src/components/editor/MinimapOverlay.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import type { LayoutResult } from '../../core/layout-types'
|
||||
|
||||
const props = defineProps<{
|
||||
layout: LayoutResult | null
|
||||
pageWidth: number // mm
|
||||
pageHeight: number // mm
|
||||
zoom: number
|
||||
panX: number
|
||||
panY: number
|
||||
containerWidth: number // px — editor canvas container genişliği
|
||||
containerHeight: number // px — editor canvas container yüksekliği
|
||||
scale: number // mm → px (zoom dahil)
|
||||
pageGap: number // px — sayfalar arası boşluk
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [x: number, y: number]
|
||||
}>()
|
||||
|
||||
const MAX_MINIMAP_WIDTH = 140
|
||||
const MAX_EXPANDED_HEIGHT = 300
|
||||
const PADDING = 6
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const scrollRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = ref(false)
|
||||
const isPointerDragging = ref(false)
|
||||
|
||||
// Offscreen canvas — sayfa içeriği cache'i (layout değiştiğinde yeniden çizilir)
|
||||
let contentCanvas: OffscreenCanvas | null = null
|
||||
let contentDirty = true
|
||||
|
||||
const pageCount = computed(() => Math.max(1, props.layout?.pages.length ?? 1))
|
||||
|
||||
// Minimap'te sayfalar arası sabit piksel boşluk
|
||||
const MINIMAP_PAGE_GAP_PX = 4
|
||||
|
||||
// Editördeki toplam yükseklik (mm, viewport hesabı için)
|
||||
const totalHeightMm = computed(() => {
|
||||
const gapMm = props.pageGap / props.scale
|
||||
return props.pageHeight * pageCount.value + gapMm * (pageCount.value - 1)
|
||||
})
|
||||
|
||||
const minimapScale = computed(() => (MAX_MINIMAP_WIDTH - PADDING * 2) / props.pageWidth)
|
||||
|
||||
const pageHeightPx = computed(() => props.pageHeight * minimapScale.value)
|
||||
|
||||
const canvasWidth = computed(() => props.pageWidth * minimapScale.value + PADDING * 2)
|
||||
const canvasHeight = computed(() => {
|
||||
const n = pageCount.value
|
||||
return pageHeightPx.value * n + MINIMAP_PAGE_GAP_PX * (n - 1) + PADDING * 2
|
||||
})
|
||||
|
||||
const singlePageMinimapH = computed(() => pageHeightPx.value + PADDING * 2)
|
||||
|
||||
// Editördeki gap'in mm karşılığı (activePageIndex hesabı için)
|
||||
const editorGapMm = computed(() => props.pageGap / props.scale)
|
||||
|
||||
const activePageIndex = computed(() => {
|
||||
const viewH = props.containerHeight - 60 - 40
|
||||
const viewCenterMm = (-props.panY + viewH / 2) / props.scale
|
||||
const stride = props.pageHeight + editorGapMm.value
|
||||
const idx = Math.floor(viewCenterMm / stride)
|
||||
return Math.max(0, Math.min(pageCount.value - 1, idx))
|
||||
})
|
||||
|
||||
const visibleHeight = computed(() => {
|
||||
if (isHovered.value || isPointerDragging.value) {
|
||||
return Math.min(canvasHeight.value, MAX_EXPANDED_HEIGHT)
|
||||
}
|
||||
return Math.min(singlePageMinimapH.value, canvasHeight.value)
|
||||
})
|
||||
|
||||
/** Sayfanın canvas üzerindeki Y pozisyonu (px) */
|
||||
function pageTopOnCanvas(pageIdx: number): number {
|
||||
return PADDING + pageIdx * (pageHeightPx.value + MINIMAP_PAGE_GAP_PX)
|
||||
}
|
||||
|
||||
const targetScrollTop = computed(() => {
|
||||
if (isHovered.value || isPointerDragging.value) {
|
||||
const vp = viewportRect.value
|
||||
const vpCenter = vp.y + vp.h / 2
|
||||
const half = visibleHeight.value / 2
|
||||
const maxScroll = canvasHeight.value - visibleHeight.value
|
||||
return Math.max(0, Math.min(maxScroll, vpCenter - half))
|
||||
}
|
||||
const top = pageTopOnCanvas(activePageIndex.value) - PADDING
|
||||
const maxScroll = canvasHeight.value - visibleHeight.value
|
||||
return Math.max(0, Math.min(maxScroll, top))
|
||||
})
|
||||
|
||||
/** Editör mm koordinatını minimap canvas px'e çevir (Y ekseni, sayfa gap'leri hesaba katarak) */
|
||||
function mmYToCanvasPx(mmY: number): number {
|
||||
const gapMm = editorGapMm.value
|
||||
const stride = props.pageHeight + gapMm
|
||||
const pageIdx = Math.min(pageCount.value - 1, Math.max(0, Math.floor(mmY / stride)))
|
||||
const withinPageMm = mmY - pageIdx * stride
|
||||
return pageTopOnCanvas(pageIdx) + withinPageMm * minimapScale.value
|
||||
}
|
||||
|
||||
const viewportRect = computed(() => {
|
||||
const s = minimapScale.value
|
||||
const pageWidthPx = props.pageWidth * props.scale
|
||||
const pageLeftPx = (props.containerWidth - pageWidthPx) / 2 + props.panX
|
||||
const pageTopPx = props.panY
|
||||
|
||||
const viewW = props.containerWidth
|
||||
const viewH = props.containerHeight - 60 - 40
|
||||
|
||||
const visLeftMm = -pageLeftPx / props.scale
|
||||
const visTopMm = -pageTopPx / props.scale
|
||||
const visWidthMm = viewW / props.scale
|
||||
const visHeightMm = viewH / props.scale
|
||||
|
||||
// Clamp to page boundaries
|
||||
const clampedLeft = Math.max(0, visLeftMm)
|
||||
const clampedTop = Math.max(0, visTopMm)
|
||||
const clampedRight = Math.min(props.pageWidth, visLeftMm + visWidthMm)
|
||||
const clampedBottom = Math.min(totalHeightMm.value, visTopMm + visHeightMm)
|
||||
|
||||
const y1 = mmYToCanvasPx(clampedTop)
|
||||
const y2 = mmYToCanvasPx(clampedBottom)
|
||||
|
||||
return {
|
||||
x: PADDING + clampedLeft * s,
|
||||
y: y1,
|
||||
w: Math.max(0, (clampedRight - clampedLeft) * s),
|
||||
h: Math.max(0, y2 - y1),
|
||||
}
|
||||
})
|
||||
|
||||
function elementColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
case 'static_text':
|
||||
case 'rich_text':
|
||||
return '#93c5fd'
|
||||
case 'container':
|
||||
return '#c4b5fd'
|
||||
case 'repeating_table':
|
||||
return '#86efac'
|
||||
case 'image':
|
||||
return '#fdba74'
|
||||
case 'line':
|
||||
return '#9ca3af'
|
||||
case 'barcode':
|
||||
return '#fca5a5'
|
||||
case 'chart':
|
||||
return '#67e8f9'
|
||||
default:
|
||||
return '#d1d5db'
|
||||
}
|
||||
}
|
||||
|
||||
// --- İki aşamalı çizim: content (pahalı, cache'li) + viewport overlay (ucuz) ---
|
||||
|
||||
/** Sayfa içeriğini offscreen canvas'a çizer — sadece layout değiştiğinde çağrılır */
|
||||
function drawContent() {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
if (!contentCanvas || contentCanvas.width !== Math.ceil(w * dpr) || contentCanvas.height !== Math.ceil(h * dpr)) {
|
||||
contentCanvas = new OffscreenCanvas(Math.ceil(w * dpr), Math.ceil(h * dpr))
|
||||
}
|
||||
|
||||
const ctx = contentCanvas.getContext('2d')!
|
||||
ctx.resetTransform()
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
const s = minimapScale.value
|
||||
const pages = props.layout?.pages ?? []
|
||||
|
||||
for (let i = 0; i < Math.max(1, pages.length); i++) {
|
||||
const px = PADDING
|
||||
const py = pageTopOnCanvas(i)
|
||||
const pw = props.pageWidth * s
|
||||
const ph = props.pageHeight * s
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(px, py, pw, ph)
|
||||
ctx.strokeStyle = '#d1d5db'
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.strokeRect(px, py, pw, ph)
|
||||
|
||||
const page = pages[i]
|
||||
if (page) {
|
||||
for (const el of page.elements) {
|
||||
if (el.element_type === 'container') continue
|
||||
const ex = px + el.x_mm * s
|
||||
const ey = py + el.y_mm * s
|
||||
const ew = Math.max(1, el.width_mm * s)
|
||||
const eh = Math.max(1, el.height_mm * s)
|
||||
|
||||
ctx.fillStyle = elementColor(el.element_type)
|
||||
ctx.globalAlpha = 0.7
|
||||
ctx.fillRect(ex, ey, ew, eh)
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentDirty = false
|
||||
}
|
||||
|
||||
/** Ana canvas'a composite: cached content + viewport dikdörtgeni */
|
||||
function compose() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
if (contentDirty || !contentCanvas) {
|
||||
drawContent()
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
canvas.width = Math.ceil(w * dpr)
|
||||
canvas.height = Math.ceil(h * dpr)
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.resetTransform()
|
||||
|
||||
// Offscreen content'i kopyala (1:1 pixel, zaten dpr ölçekli)
|
||||
ctx.drawImage(contentCanvas!, 0, 0)
|
||||
|
||||
// Viewport dikdörtgenini çiz (dpr ölçekli)
|
||||
ctx.scale(dpr, dpr)
|
||||
const v = viewportRect.value
|
||||
ctx.strokeStyle = '#2563eb'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.strokeRect(v.x, v.y, v.w, v.h)
|
||||
ctx.fillStyle = 'rgba(37, 99, 235, 0.08)'
|
||||
ctx.fillRect(v.x, v.y, v.w, v.h)
|
||||
}
|
||||
|
||||
// rAF throttle — aynı frame'de birden fazla compose çağrısını engelle
|
||||
let composeRAF: number | null = null
|
||||
function scheduleCompose() {
|
||||
if (composeRAF !== null) return
|
||||
composeRAF = requestAnimationFrame(() => {
|
||||
composeRAF = null
|
||||
compose()
|
||||
})
|
||||
}
|
||||
|
||||
// --- Scroll yönetimi ---
|
||||
|
||||
function smoothScrollTo(target: number) {
|
||||
scrollRef.value?.scrollTo({ top: target, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function jumpScrollTo(target: number) {
|
||||
if (scrollRef.value) scrollRef.value.scrollTop = target
|
||||
}
|
||||
|
||||
// --- Pointer etkileşimi ---
|
||||
|
||||
/** Canvas px → editör mm (sayfa gap dönüşümü dahil) */
|
||||
function canvasToMm(clientX: number, clientY: number): { mmX: number; mmY: number } {
|
||||
const canvas = canvasRef.value!
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const mx = clientX - rect.left - PADDING
|
||||
const my = clientY - rect.top - PADDING
|
||||
const s = minimapScale.value
|
||||
|
||||
// Y: canvas px'ten hangi sayfadayız bul, editör mm'e çevir
|
||||
const pageStridePx = pageHeightPx.value + MINIMAP_PAGE_GAP_PX
|
||||
const pageIdx = Math.min(pageCount.value - 1, Math.max(0, Math.floor(my / pageStridePx)))
|
||||
const withinPagePx = my - pageIdx * pageStridePx
|
||||
const withinPageMm = withinPagePx / s
|
||||
const editorStride = props.pageHeight + editorGapMm.value
|
||||
const mmY = pageIdx * editorStride + withinPageMm
|
||||
|
||||
return { mmX: mx / s, mmY }
|
||||
}
|
||||
|
||||
function navigateTo(clientX: number, clientY: number) {
|
||||
const { mmX, mmY } = canvasToMm(clientX, clientY)
|
||||
const viewW = props.containerWidth
|
||||
const viewH = props.containerHeight - 60 - 40
|
||||
const pageWidthPx = props.pageWidth * props.scale
|
||||
|
||||
const newPanX = -(mmX * props.scale) + viewW / 2 - (viewW - pageWidthPx) / 2
|
||||
const newPanY = -(mmY * props.scale) + viewH / 2
|
||||
|
||||
emit('navigate', newPanX, newPanY)
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isPointerDragging.value = true
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
navigateTo(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isPointerDragging.value) return
|
||||
navigateTo(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (isPointerDragging.value) {
|
||||
isPointerDragging.value = false
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
isHovered.value = true
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (!isPointerDragging.value) {
|
||||
isHovered.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(isPointerDragging, (dragging) => {
|
||||
if (!dragging) {
|
||||
nextTick(() => {
|
||||
const el = scrollRef.value
|
||||
if (el && !el.matches(':hover')) {
|
||||
isHovered.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Layout değiştiğinde content'i dirty işaretle + tam redraw
|
||||
watch(() => props.layout, () => {
|
||||
contentDirty = true
|
||||
scheduleCompose()
|
||||
}, { deep: true })
|
||||
|
||||
// Scale değiştiğinde (zoom) content'i de yeniden çizmek gerekir (gapMm değişir)
|
||||
watch(() => props.scale, () => {
|
||||
contentDirty = true
|
||||
scheduleCompose()
|
||||
})
|
||||
|
||||
// Pan değiştiğinde sadece viewport overlay'i yeniden çiz (ucuz)
|
||||
// Minimap drag sırasında scroll yapma — kullanıcı zaten sürükleyerek kontrol ediyor
|
||||
watch([() => props.panX, () => props.panY], () => {
|
||||
scheduleCompose()
|
||||
if (!isPointerDragging.value) {
|
||||
smoothScrollTo(targetScrollTop.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Zoom değiştiğinde scroll da güncelle
|
||||
watch(() => props.zoom, () => {
|
||||
smoothScrollTo(targetScrollTop.value)
|
||||
})
|
||||
|
||||
// Container boyutu değiştiğinde
|
||||
watch([() => props.containerWidth, () => props.containerHeight], () => {
|
||||
scheduleCompose()
|
||||
})
|
||||
|
||||
// Hover/collapse durumu değiştiğinde
|
||||
watch([isHovered, isPointerDragging], () => {
|
||||
nextTick(() => {
|
||||
if (isHovered.value || isPointerDragging.value) {
|
||||
smoothScrollTo(targetScrollTop.value)
|
||||
} else {
|
||||
jumpScrollTo(targetScrollTop.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
drawContent()
|
||||
compose()
|
||||
jumpScrollTo(targetScrollTop.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="minimap"
|
||||
:class="{
|
||||
'minimap--expanded': isHovered || isPointerDragging,
|
||||
'minimap--dragging': isPointerDragging,
|
||||
}"
|
||||
:style="{ width: `${canvasWidth}px`, height: `${visibleHeight}px` }"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="minimap__scroll"
|
||||
:style="{ height: `${visibleHeight}px` }"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.minimap {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
backdrop-filter: blur(4px);
|
||||
overflow: hidden;
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
|
||||
.minimap--expanded {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 2px 12px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.minimap--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.minimap__scroll {
|
||||
overflow: hidden;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.minimap__scroll canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,12 @@ const props = defineProps<{
|
||||
panX: number
|
||||
/** Pan offset Y (px) */
|
||||
panY: number
|
||||
/** editor-canvas content width (px) — ResizeObserver'dan */
|
||||
containerWidth: number
|
||||
/** Sayfa sayısı */
|
||||
pageCount: number
|
||||
/** Sayfalar arası boşluk (px) */
|
||||
pageGap?: number
|
||||
/** Cetvel kalınlığı px */
|
||||
rulerSize?: number
|
||||
}>()
|
||||
@@ -69,19 +75,8 @@ function drawTicks(
|
||||
size: number,
|
||||
) {
|
||||
const s = props.scale
|
||||
const pageMm = direction === 'horizontal' ? props.pageWidth : props.pageHeight
|
||||
const pan = direction === 'horizontal' ? props.panX : props.panY
|
||||
|
||||
// Sayfa başlangıcı: ortaya hizalı + pan
|
||||
// EditorCanvas sayfayı ortalar, ruler da buna uymalı
|
||||
// Yatay: canvas ortası - sayfa genişliği/2
|
||||
// Sayfanın canvas üzerindeki orijin px'i
|
||||
const canvasCenter =
|
||||
direction === 'horizontal'
|
||||
? length / 2 // flex centering approximation
|
||||
: 40 // EditorCanvas padding-top: 40px
|
||||
|
||||
const pageStartPx = canvasCenter - (pageMm * s) / 2 + pan
|
||||
const rulerSz = RULER_SIZE.value
|
||||
const gap = props.pageGap ?? 24
|
||||
|
||||
// Tick aralığı belirleme (zoom'a göre)
|
||||
const mmPerPx = 1 / s
|
||||
@@ -98,11 +93,41 @@ function drawTicks(
|
||||
ctx.font = '9px system-ui, sans-serif'
|
||||
ctx.textBaseline = 'top'
|
||||
|
||||
// Sayfanın mm aralığını çiz
|
||||
const startMm = 0
|
||||
const endMm = pageMm
|
||||
if (direction === 'horizontal') {
|
||||
// Yatay cetvel: tek sayfa genişliği, flex-center ile hizalı
|
||||
// editor-canvas padding: left=60, right=40; ruler canvas left=rulerSize
|
||||
// pageLeft_in_wrapper = 60 + (containerWidth - pageWidthPx) / 2
|
||||
// pageLeft_in_ruler = pageLeft_in_wrapper - rulerSz + panX
|
||||
const pageWidthPx = props.pageWidth * s
|
||||
const pageStartPx = (60 - rulerSz) + (props.containerWidth - pageWidthPx) / 2 + props.panX
|
||||
|
||||
for (let mm = startMm; mm <= endMm; mm += tickMm) {
|
||||
drawPageTicks(ctx, direction, length, size, pageStartPx, props.pageWidth, tickMm)
|
||||
} else {
|
||||
// Dikey cetvel: her sayfa için ayrı tick çiz
|
||||
// editor-canvas padding-top=60; ruler canvas top=rulerSize
|
||||
// pageTop for page i = (60 - rulerSz) + panY + i * (pageHeightPx + gap)
|
||||
const pageHeightPx = props.pageHeight * s
|
||||
const pageCount = Math.max(1, props.pageCount)
|
||||
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const pageStartPx = (60 - rulerSz) + props.panY + i * (pageHeightPx + gap)
|
||||
drawPageTicks(ctx, direction, length, size, pageStartPx, props.pageHeight, tickMm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawPageTicks(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
length: number,
|
||||
size: number,
|
||||
pageStartPx: number,
|
||||
pageMm: number,
|
||||
tickMm: number,
|
||||
) {
|
||||
const s = props.scale
|
||||
|
||||
for (let mm = 0; mm <= pageMm; mm += tickMm) {
|
||||
const px = pageStartPx + mm * s
|
||||
|
||||
if (px < -10 || px > length + 10) continue
|
||||
@@ -141,7 +166,7 @@ function drawTicks(
|
||||
}
|
||||
}
|
||||
|
||||
// Sayfa kenar çizgileri (margin göstergesi)
|
||||
// Sayfa kenar çizgileri
|
||||
ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'
|
||||
ctx.lineWidth = 1
|
||||
const startPx = pageStartPx
|
||||
@@ -159,6 +184,11 @@ function drawTicks(
|
||||
ctx.lineTo(size, endPx)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Renkleri geri al (sonraki sayfa için)
|
||||
ctx.fillStyle = '#94a3b8'
|
||||
ctx.strokeStyle = '#94a3b8'
|
||||
ctx.lineWidth = 0.5
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
@@ -166,7 +196,7 @@ function redraw() {
|
||||
drawRuler(vCanvas.value, 'vertical')
|
||||
}
|
||||
|
||||
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight], redraw)
|
||||
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight, props.containerWidth, props.pageCount], redraw)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
@@ -205,7 +235,7 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 20px;
|
||||
right: 0;
|
||||
width: calc(100% - 20px);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -214,7 +244,7 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: calc(100% - 20px);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
dreport-core = { version = "0.2.0", path = "../core", registry = "gitea" }
|
||||
dexpr = { version = "0.1.0", registry = "gitea" }
|
||||
dexpr = { version = "0.3.0", registry = "gitea" }
|
||||
rust_decimal = "1.41"
|
||||
taffy = "0.9"
|
||||
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }
|
||||
|
||||
@@ -64,7 +64,9 @@ pub struct YTick {
|
||||
|
||||
pub struct XLabelLayout {
|
||||
pub labels: Vec<XLabel>,
|
||||
pub needs_rotate: bool,
|
||||
/// Rotation angle in degrees (0 = horizontal, 90 = fully vertical).
|
||||
/// Dynamically computed based on available space vs label length.
|
||||
pub rotate_angle: f64,
|
||||
}
|
||||
|
||||
pub struct XLabel {
|
||||
@@ -545,14 +547,14 @@ pub fn compute_chart_layout(
|
||||
} 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 {
|
||||
let char_w_mm = 1.1;
|
||||
let rotate_angle = compute_label_rotation(max_label_len, cat_width);
|
||||
if rotate_angle > 0.0 {
|
||||
let char_w_mm = 2.5 * 0.6;
|
||||
let max_text_w = max_label_len as f64 * char_w_mm;
|
||||
let label_v = max_text_w * 0.707;
|
||||
let angle_rad = rotate_angle.to_radians();
|
||||
let label_v = max_text_w * angle_rad.sin();
|
||||
margin_bottom += label_v.clamp(6.0, 25.0);
|
||||
let label_h = max_text_w * 0.707;
|
||||
let label_h = max_text_w * angle_rad.cos();
|
||||
let extra_left = (label_h - cat_width / 2.0).max(0.0);
|
||||
margin_left += extra_left.min(10.0);
|
||||
} else {
|
||||
@@ -622,6 +624,29 @@ pub fn compute_y_axis(
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute dynamic label rotation angle (degrees) based on available space.
|
||||
/// Uses Chart.js-style algorithm: rotate only when labels overflow their slot,
|
||||
/// and use the minimum angle that prevents overlap.
|
||||
fn compute_label_rotation(max_label_len: usize, slot_width: f64) -> f64 {
|
||||
let label_font_size = 2.5_f64;
|
||||
let char_w_mm = label_font_size * 0.6;
|
||||
let max_label_w = max_label_len as f64 * char_w_mm;
|
||||
let padding = label_font_size * 0.5;
|
||||
|
||||
// Labels fit horizontally — no rotation needed
|
||||
if (max_label_w + padding) <= slot_width {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Chart.js Constraint A: sin(angle) = (label_height + padding) / slot_width
|
||||
// This finds the minimum angle where the rotated label's projected height
|
||||
// fits within the tick slot width, preventing horizontal overlap.
|
||||
let label_h = label_font_size;
|
||||
let sin_val = ((label_h + padding) / slot_width).clamp(0.0, 1.0);
|
||||
let angle_deg = sin_val.asin().to_degrees();
|
||||
angle_deg.clamp(0.0, 50.0)
|
||||
}
|
||||
|
||||
/// Compute X label positions for bar chart (slot-based spacing).
|
||||
pub fn compute_x_labels_bar(
|
||||
categories: &[String],
|
||||
@@ -633,12 +658,12 @@ pub fn compute_x_labels_bar(
|
||||
if n_cats == 0 {
|
||||
return XLabelLayout {
|
||||
labels: vec![],
|
||||
needs_rotate: false,
|
||||
rotate_angle: 0.0,
|
||||
};
|
||||
}
|
||||
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);
|
||||
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
|
||||
let rotate_angle = compute_label_rotation(max_label_len, cat_width);
|
||||
let labels = categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -650,7 +675,7 @@ pub fn compute_x_labels_bar(
|
||||
.collect();
|
||||
XLabelLayout {
|
||||
labels,
|
||||
needs_rotate,
|
||||
rotate_angle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,7 +690,7 @@ pub fn compute_x_labels_line(
|
||||
if n_cats == 0 {
|
||||
return XLabelLayout {
|
||||
labels: vec![],
|
||||
needs_rotate: false,
|
||||
rotate_angle: 0.0,
|
||||
};
|
||||
}
|
||||
let spacing = if n_cats == 1 {
|
||||
@@ -673,8 +698,8 @@ pub fn compute_x_labels_line(
|
||||
} 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);
|
||||
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
|
||||
let rotate_angle = compute_label_rotation(max_label_len, spacing);
|
||||
let labels = categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -693,7 +718,7 @@ pub fn compute_x_labels_line(
|
||||
.collect();
|
||||
XLabelLayout {
|
||||
labels,
|
||||
needs_rotate,
|
||||
rotate_angle,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -337,12 +337,13 @@ fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) {
|
||||
}
|
||||
|
||||
fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout) {
|
||||
let angle = x_labels.rotate_angle;
|
||||
for label in &x_labels.labels {
|
||||
if x_labels.needs_rotate {
|
||||
if angle > 0.0 {
|
||||
write!(
|
||||
svg,
|
||||
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-45,{:.2},{:.2})">{}</text>"##,
|
||||
label.x, label.y, label.x, label.y, escape_xml(&label.text)
|
||||
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-{:.1},{:.2},{:.2})">{}</text>"##,
|
||||
label.x, label.y, angle, label.x, label.y, escape_xml(&label.text)
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
|
||||
@@ -65,6 +65,10 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
|
||||
.collect();
|
||||
format!("{{{}}}", items.join(", "))
|
||||
}
|
||||
DexprValue::List(list) => {
|
||||
let items: Vec<String> = list.iter().map(|v| dexpr_value_to_string(v)).collect();
|
||||
format!("[{}]", items.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,4 +362,31 @@ mod tests {
|
||||
};
|
||||
assert_eq!(format_currency("1500.25", &config), "$1,500.25");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_field_sum() {
|
||||
let data = json!({
|
||||
"kalemler": [
|
||||
{"adi": "A", "tutar": 100},
|
||||
{"adi": "B", "tutar": 200},
|
||||
{"adi": "C", "tutar": 50}
|
||||
]
|
||||
});
|
||||
assert_eq!(evaluate_expression("kalemler.tutar.sum()", &data), "350");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_field_sum_in_arithmetic() {
|
||||
let data = json!({
|
||||
"kalemler": [
|
||||
{"tutar": 1000},
|
||||
{"tutar": 2000}
|
||||
],
|
||||
"toplamlar": {"kdvOrani": 20}
|
||||
});
|
||||
assert_eq!(
|
||||
evaluate_expression("kalemler.tutar.sum() * toplamlar.kdvOrani / 100", &data),
|
||||
"600"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1406,11 +1406,13 @@ fn render_chart_x_labels(
|
||||
fonts: &FontCollection,
|
||||
measurer: &mut TextMeasurer,
|
||||
) {
|
||||
let angle = x_labels.rotate_angle;
|
||||
for label in &x_labels.labels {
|
||||
if x_labels.needs_rotate {
|
||||
if angle > 0.0 {
|
||||
surface.push_transform(&Transform::from_translate(pt(label.x), pt(label.y)));
|
||||
let c = std::f32::consts::FRAC_PI_4.cos();
|
||||
let s = std::f32::consts::FRAC_PI_4.sin();
|
||||
let angle_rad = (angle as f32).to_radians();
|
||||
let c = angle_rad.cos();
|
||||
let s = angle_rad.sin();
|
||||
surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0));
|
||||
chart_text_end(
|
||||
surface,
|
||||
|
||||
Reference in New Issue
Block a user