%% Copyright 2025-2026 Lukas C. Bossert %% This work may be distributed and/or modified under the LaTeX Project %% Public License, version 1.3c or later. See LICENSE for the full notice. %% LPPL maintenance status: maintained. %% Current Maintainer: Lukas C. Bossert . \NeedsTeXFormat{LaTeX2e}[2022-06-01] \ProvidesExplPackage{ddv}{2026/10/01}{1.0.0} {CSV-driven concentric arc charts} \ExplSyntaxOff \RequirePackage{xcolor} \RequirePackage{datatool} \RequirePackage{wheelchart} \usetikzlibrary{decorations.text} \ExplSyntaxOn % Chart state is local; only the private database serial survives a chart. \int_new:N \g__ddv_database_int \int_new:N \l__ddv_radius_int \int_new:N \l__ddv_inner_int \int_new:N \l__ddv_rows_int \int_new:N \l__ddv_index_int \tl_new:N \l__ddv_database_tl \tl_new:N \l__ddv_description_tl \tl_new:N \l__ddv_color_mode_tl \bool_new:N \l__ddv_valid_bool \clist_new:N \l__ddv_rgb_clist \msg_new:nnn {ddv}{invalid-value} {Invalid~value~'#2'~for~'#1'.} \msg_new:nnn {ddv}{file-not-found} {Dataset~'#1'~not~found;~chart~skipped.} \msg_new:nnn {ddv}{missing-column} {Dataset~is~missing~required~column~'#1';~chart~skipped.} \msg_new:nnn {ddv}{empty-category} {No~rows~match~the~dataset~filter;~chart~skipped.} \msg_new:nnn {ddv}{invalid-angle} {Invalid~#1~value~'#2'.~Use~a~decimal~number;~totalangle~must~be~in~(0,360].} % Accept literal integers, with a lower bound appropriate to the key. \cs_new_protected:Npn \__ddv_integer:Nnnn #1#2#3#4 { \regex_match:nnTF {\A\s*\d{1,4}\s*\Z} {#4} { \int_compare:nNnTF {#4} < {#3} {\msg_error:nnnn {ddv}{invalid-value}{#2}{#4}} {\int_set:Nn #1 {#4}} } {\msg_error:nnnn {ddv}{invalid-value}{#2}{#4}} } % RGB triples are optional convenience syntax; other values go to xcolor. \cs_new_protected:Npn \__ddv_color:nn #1#2 { \tl_if_in:nnTF {#2} {,} { \regex_match:nnTF {\A\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\Z} {#2} { \clist_set:Nn \l__ddv_rgb_clist {#2} \bool_set_true:N \l_tmpa_bool \clist_map_inline:Nn \l__ddv_rgb_clist {\int_compare:nNnT {##1} > {255} {\bool_set_false:N \l_tmpa_bool}} \bool_if:NTF \l_tmpa_bool {\definecolor{#1}{RGB}{#2}} {\msg_error:nnnn {ddv}{invalid-value}{RGB~color}{#2}} } {\msg_error:nnnn {ddv}{invalid-value}{RGB~color}{#2}} } {\colorlet{#1}{#2}} } \NewDocumentCommand\ddvWheelchartSlices{ O{} m}{ \wheelchart[ arc~data=|\fontsize{\int_use:N \l_ddv_slices_fontsize_int}{0}\selectfont\l_ddv_slices_font_tl|\WCvarA\WCvarB, arc~data~dir={\WCmidangle<180?1:-1}, arc~data~pos=0.5, arc~data~style={text~color=ddv-slices-text}, data=, gap~radius=0.05, radius={1+\thestagesRadius}{2+\thestagesRadius}, slices~end~arc={1}{0}, slices~start~arc={1}{0}, slices~style=ddv-slice-background, start~angle=\WCvarC, % wheelchart resolves total angle before its per-slice variables exist. total~angle=\ddvAngle, value=1, #1 ]{#2} } \NewDocumentCommand\ddvWheelchartStages{ O{} m }{ \wheelchart[ arc~data=|\fontsize{\int_use:N \l_ddv_stages_fontsize_int}{0}\selectfont\l_ddv_stages_font_tl|\WCvarA, arc~data~dir={\WCmidangle<180?1:-1}, arc~data~pos=0.5, arc~data~style={text~color=ddv-stages-text}, data=, gap=.1, radius={1+\thestagesRadius}{2+\thestagesRadius}, slices~arrow={.5}{0}, slices~style=ddv-stages-background, value=1, #1 ]{#2} } \NewDocumentCommand\ddvWheelchartTitle{ O{} m }{ \wheelchart[ arc~data=|\fontsize{\int_use:N \l_ddv_title_fontsize_int}{20}\selectfont\l_ddv_title_font_tl|\WCvarA, arc~data~dir={\WCmidangle<181?1:-1}, arc~data~pos=.5, arc~data~style={text~color = ddv-title-text}, arc~pos=1, data=, slices~style={fill=none}, start~half=90, radius={2+\thestagesRadius}{6+\thestagesRadius}, value=1, #1 ]{ {#2} } } % Accept both {a,b} and the extra braces used in early examples: {{a,b}}. \cs_new_protected:Npn \__ddv_clist_set:Nn #1#2 { \tl_if_single:nTF {#2} { \tl_if_head_is_group:nTF {#2} {\clist_set:Nn #1 #2} {\clist_set:Nn #1 {#2}} } {\clist_set:Nn #1 {#2}} } \cs_new_protected:Npn \__ddv_tl_set:Nn #1#2 { \tl_if_single:nTF {#2} { \tl_if_head_is_group:nTF {#2} {\tl_set:Nn #1 #2} {\tl_set:Nn #1 {#2}} } {\tl_set:Nn #1 {#2}} } % Nested options retain braces around values containing commas or equals. \clist_map_inline:nn {title,stages,dataset} { \cs_new_protected:cpn {__ddv_set_#1_name:n} ##1 {\tl_set:cn {l_ddv_#1_name_tl} {##1}} \cs_new_protected:cpn {__ddv_set_#1_attributes:nn} ##1##2 {\keys_set:nn {ddv/#1} {##1={##2}}} \keys_define:nn {ddv} { #1 .code:n = {\exp_args:Ncc \keyval_parse:NNn {__ddv_set_#1_name:n}{__ddv_set_#1_attributes:nn}{##1}}, } } \keys_define:nn {ddv} { slices .code:n = {\keys_set:nn {ddv/slices}{#1}}, innercirclesize .code:n = {\__ddv_integer:Nnnn \l__ddv_inner_int {innercirclesize}{0}{#1}}, innercirclesize .initial:n = 4, } % The three text layers share typography keys, without expanding font code. \clist_map_inline:nn {title,stages,slices} { \int_new:c {l_ddv_#1_fontsize_int} \tl_new:c {l_ddv_#1_options_tl} \keys_define:nn {ddv/#1} { font .tl_set:c = {l_ddv_#1_font_tl}, font .initial:n = \bfseries, fontsize .code:n = {\exp_args:Nc \__ddv_integer:Nnnn {l_ddv_#1_fontsize_int} {#1/fontsize}{1}{##1}}, fontsize .initial:n = 15, fontcolor .code:n = {\__ddv_color:nn {ddv-#1-text}{##1}}, fontcolor .initial:n = black!50, options .code:n = {\exp_args:Nc \__ddv_tl_set:Nn {l_ddv_#1_options_tl}{##1}}, } } \keys_define:nn {ddv/title} {name .tl_set:N = \l_ddv_title_name_tl} \tl_new:N \l_ddv_stages_name_tl \keys_define:nn {ddv/stages} { name .code:n = {\__ddv_tl_set:Nn \l_ddv_stages_name_tl {#1}}, fontcolor .initial:n = white, bgcolor .code:n = {\__ddv_color:nn {ddv-stages-background}{#1}}, bgcolor .initial:n = blue, inside .bool_set:N = \l_ddv_stages_inside_bool, inside .initial:n = false, } \tl_new:N \l__ddv_first_color_tl \tl_new:N \l__ddv_last_color_tl \keys_define:nn {ddv/slices} { bgcolor .code:n = { \__ddv_color:nn {ddv-slices-background}{#1} \tl_set:Nn \l__ddv_color_mode_tl {uniform} }, bgcolor .initial:n = blue, bgcolorseries .code:n = { \__ddv_clist_set:Nn \l_tmpa_clist {#1} \int_compare:nNnTF {\clist_count:N \l_tmpa_clist} = {2} { \clist_pop:NN \l_tmpa_clist \l__ddv_first_color_tl \clist_pop:NN \l_tmpa_clist \l__ddv_last_color_tl \exp_args:NnV \__ddv_color:nn {ddv-series-first}\l__ddv_first_color_tl \exp_args:NnV \__ddv_color:nn {ddv-series-last}\l__ddv_last_color_tl \tl_set:Nn \l__ddv_color_mode_tl {series} } {\msg_error:nnnn {ddv}{invalid-value}{slices/bgcolorseries}{#1}} }, bgcolorseries .default:n = {blue,red}, % Compatibility switches; the last color-selection key wins. sliceColor .choice:, sliceColor/true .code:n = {\tl_set:Nn \l__ddv_color_mode_tl {dataset}}, sliceColor/false .code:n = {\tl_set:Nn \l__ddv_color_mode_tl {uniform}}, sliceColor .default:n = true, sliceColor .initial:n = true, bgcolorSeries .choice:, bgcolorSeries/true .code:n = {\tl_set:Nn \l__ddv_color_mode_tl {series}}, bgcolorSeries/false .code:n = {\tl_set:Nn \l__ddv_color_mode_tl {uniform}}, bgcolorSeries .default:n = true, description .bool_set:N = \l_ddv_slices_description_bool, description .initial:n = false, } \colorlet{ddv-series-first}{blue} \colorlet{ddv-series-last}{red} \tl_new:N \l_ddv_dataset_sorting_tl \clist_new:N \l_ddv_dataset_filter_clist \keys_define:nn {ddv/dataset} { name .tl_set:N = \l_ddv_dataset_name_tl, filter .code:n = {\__ddv_clist_set:Nn \l_ddv_dataset_filter_clist {#1}}, filter .initial:n = none, sorting .code:n = { \__ddv_clist_set:Nn \l_tmpa_clist {#1} \tl_set:Nx \l_ddv_dataset_sorting_tl {\exp_not:V \l_tmpa_clist} }, } \ProcessKeyOptions[ddv] \prg_new_conditional:Npnn \__ddv_selected: {T} { \clist_if_in:NnTF \l_ddv_dataset_filter_clist {none} {\prg_return_true:} { \exp_args:NNV \clist_if_in:NnTF \l_ddv_dataset_filter_clist \ddvCategory {\prg_return_true:}{\prg_return_false:} } } \cs_new_protected:Npn \__ddv_angle:nn #1#2 { \regex_match:nnTF {\A\s*[+\-]?(\d+(\.\d*)?|\.\d+)\s*\Z} {#2} { \str_if_eq:nnT {#1}{totalangle} { \fp_compare:nF {0 < (#2) <= 360} { \bool_set_false:N \l__ddv_valid_bool \msg_error:nnnn {ddv}{invalid-angle}{#1}{#2} } } } { \bool_set_false:N \l__ddv_valid_bool \msg_error:nnnn {ddv}{invalid-angle}{#1}{#2} } } \cs_new_protected:Npn \__ddv_load: { \file_if_exist:VTF \l_ddv_dataset_name_tl { % Never reuse or delete a database belonging to the calling document. \int_gincr:N \g__ddv_database_int \tl_set:Nx \l__ddv_database_tl {ddv-private-\int_use:N \g__ddv_database_int} \DTLifdbexists{\l__ddv_database_tl}{\__ddv_load:} { \DTLloaddb{\l__ddv_database_tl}{\l_ddv_dataset_name_tl} \clist_map_inline:nn {category,name,description,startangle,totalangle,color} { \DTLifhaskey{\l__ddv_database_tl}{##1}{} { \bool_set_false:N \l__ddv_valid_bool \msg_error:nnn {ddv}{missing-column}{##1} } } \tl_if_empty:NF \l_ddv_dataset_sorting_tl { \exp_args:NV \clist_map_inline:nn \l_ddv_dataset_sorting_tl { \DTLifhaskey{\l__ddv_database_tl}{##1}{} { \bool_set_false:N \l__ddv_valid_bool \msg_error:nnn {ddv}{missing-column}{##1} } } \bool_if:NT \l__ddv_valid_bool {\exp_args:NNV \DTLsort * \l_ddv_dataset_sorting_tl {\l__ddv_database_tl}} } \bool_if:NT \l__ddv_valid_bool { \DTLforeach*{\l__ddv_database_tl} {\ddvCategory=category,\ddvStart=startangle,\ddvAngle=totalangle} { \__ddv_selected:T { \int_incr:N \l__ddv_rows_int \exp_args:NnV \__ddv_angle:nn {startangle}\ddvStart \exp_args:NnV \__ddv_angle:nn {totalangle}\ddvAngle } } \int_compare:nNnT {\l__ddv_rows_int} = {0} { \bool_set_false:N \l__ddv_valid_bool \msg_warning:nn {ddv}{empty-category} } } } } { \bool_set_false:N \l__ddv_valid_bool \msg_error:nnV {ddv}{file-not-found}\l_ddv_dataset_name_tl } } \cs_new_protected:Npn \__ddv_slices: { \DTLforeach*{\l__ddv_database_tl} {\ddvCategory=category,\ddvName=name,\ddvDescription=description, \ddvStart=startangle,\ddvAngle=totalangle,\ddvColor=color} { \__ddv_selected:T { \int_incr:N \l__ddv_radius_int \str_case:Vn \l__ddv_color_mode_tl { {dataset}{\exp_args:NnV \__ddv_color:nn {ddv-slice-background}\ddvColor} {uniform}{\colorlet{ddv-slice-background}{ddv-slices-background}} {series} { % Include both endpoints; a one-row chart uses the first. \int_compare:nNnTF {\l__ddv_rows_int} > {1} {\tl_set:Nx \l_tmpa_tl {\fp_eval:n {100*(1-\l__ddv_index_int/(\l__ddv_rows_int-1))}}} {\tl_set:Nn \l_tmpa_tl {100}} \colorlet{ddv-slice-background} {ddv-series-first!\l_tmpa_tl!ddv-series-last} } } \tl_clear:N \l__ddv_description_tl \bool_if:NT \l_ddv_slices_description_bool { \DTLifnullorempty{\ddvDescription}{} {\tl_set:Nn \l__ddv_description_tl {\space(\ddvDescription)}} } \exp_args:NNV \ddvWheelchartSlices [\l_ddv_slices_options_tl] {{\ddvName}/{\l__ddv_description_tl}/{\ddvStart}/{\ddvAngle}} \int_incr:N \l__ddv_index_int } } } \cs_new_protected:Npn \__ddv_draw: { \begin{tikzpicture} % Preserve the documented radius hook without allocating a global counter. \cs_set:Npn \thestagesRadius {\int_use:N \l__ddv_radius_int} \tl_if_empty:NF \l_ddv_stages_name_tl { \bool_if:NT \l_ddv_stages_inside_bool {\exp_args:NNV \ddvWheelchartStages [\l_ddv_stages_options_tl] {\l_ddv_stages_name_tl}} } \tl_if_empty:NF \l__ddv_database_tl {\__ddv_slices:} \tl_if_empty:NF \l_ddv_stages_name_tl { \bool_if:NF \l_ddv_stages_inside_bool { \int_add:Nn \l__ddv_radius_int {2} \use:e { \exp_not:N \ddvWheelchartStages [slices~arrow={1}{-1},gap=.8, radius={.7+\exp_not:N \thestagesRadius}{2.7+\exp_not:N \thestagesRadius}, \exp_not:V \l_ddv_stages_options_tl] }{\l_ddv_stages_name_tl} \int_incr:N \l__ddv_radius_int } } \tl_if_empty:NF \l_ddv_title_name_tl {\exp_args:NNV \ddvWheelchartTitle [\l_ddv_title_options_tl] {\l_ddv_title_name_tl}} \end{tikzpicture} } \NewDocumentCommand \DDV {m} { \group_begin: \keys_set:nn {ddv}{#1} \int_set_eq:NN \l__ddv_radius_int \l__ddv_inner_int \int_zero:N \l__ddv_rows_int \int_zero:N \l__ddv_index_int \tl_clear:N \l__ddv_database_tl \bool_set_true:N \l__ddv_valid_bool \tl_if_empty:NF \l_ddv_dataset_name_tl {\__ddv_load:} \bool_if:NT \l__ddv_valid_bool {\__ddv_draw:} \tl_if_empty:NF \l__ddv_database_tl {\DTLgdeletedb{\l__ddv_database_tl}} \group_end: } \ExplSyntaxOff \endinput