///|
/// A single point in a generated chart preview.
pub(all) struct CsvChartPoint {
  label : String
  value : Double
  count : Int
} derive(Eq, Debug)

///|
/// Data model used by JSON, SVG, HTML, CLI, and browser chart previews.
pub(all) struct CsvChartSpec {
  kind : String
  title : String
  label_column : String
  value_column : String
  measure : String
  points : Array[CsvChartPoint]
  skipped_rows : Int
  warnings : Array[String]
} derive(Eq, Debug)

///|
/// Build an automatic bar-chart preview from CSV text.
pub fn chart_csv_spec(input : String) -> CsvChartSpec {
  chart_csv_spec_kind(input, "bar")
}

///|
/// Build an automatic chart preview from CSV text.
///
/// Supported kinds are `bar`, `line`, and `pie`. Unknown values fall back to
/// `bar`. The function chooses a label column and a numeric value column when
/// possible; if no numeric column exists, it generates category counts.
pub fn chart_csv_spec_kind(input : String, kind : String) -> CsvChartSpec {
  chart_auto_spec(parse_table_auto(input), kind)
}

///|
/// Build a chart preview from CSV text with explicit label and value columns.
pub fn chart_csv_spec_with_columns(
  input : String,
  kind : String,
  label_column : String,
  value_column : String,
) -> CsvChartSpec {
  chart_table_spec(parse_table_auto(input), kind, label_column, value_column)
}

///|
/// Render an automatic chart spec as machine-readable JSON.
pub fn chart_csv_json(input : String, kind : String) -> String {
  chart_spec_json(chart_csv_spec_kind(input, kind))
}

///|
/// Render a chart spec with explicit columns as machine-readable JSON.
pub fn chart_csv_json_with_columns(
  input : String,
  kind : String,
  label_column : String,
  value_column : String,
) -> String {
  chart_spec_json(
    chart_csv_spec_with_columns(input, kind, label_column, value_column),
  )
}

///|
/// Render an automatic chart preview as standalone SVG.
pub fn chart_csv_svg(input : String, kind : String) -> String {
  chart_spec_svg(chart_csv_spec_kind(input, kind))
}

///|
/// Render a chart preview with explicit columns as standalone SVG.
pub fn chart_csv_svg_with_columns(
  input : String,
  kind : String,
  label_column : String,
  value_column : String,
) -> String {
  chart_spec_svg(
    chart_csv_spec_with_columns(input, kind, label_column, value_column),
  )
}

///|
/// Render an automatic chart preview as a standalone HTML document.
pub fn chart_csv_html(input : String, kind : String) -> String {
  chart_spec_html(chart_csv_spec_kind(input, kind))
}

///|
/// Render a chart preview with explicit columns as a standalone HTML document.
pub fn chart_csv_html_with_columns(
  input : String,
  kind : String,
  label_column : String,
  value_column : String,
) -> String {
  chart_spec_html(
    chart_csv_spec_with_columns(input, kind, label_column, value_column),
  )
}

///|
/// Build an automatic chart spec from a parsed table.
pub fn chart_auto_spec(table : CsvTable, kind : String) -> CsvChartSpec {
  let normalized = chart_normalize_kind(kind)
  let label_column = chart_pick_label_column(table)
  match chart_pick_value_column(table) {
    Some(value_column) =>
      chart_table_spec(table, normalized, label_column, value_column)
    None => chart_count_spec(table, normalized, label_column)
  }
}

///|
/// Build a chart spec from explicit table columns. Duplicate labels are
/// aggregated by summing values in first-seen order.
pub fn chart_table_spec(
  table : CsvTable,
  kind : String,
  label_column : String,
  value_column : String,
) -> CsvChartSpec {
  let normalized = chart_normalize_kind(kind)
  let warnings : Array[String] = Array::new()
  let points : Array[CsvChartPoint] = Array::new()
  match
    (
      chart_column_index(table.headers, label_column),
      chart_column_index(table.headers, value_column),
    ) {
    (Some(label_index), Some(value_index)) => {
      let labels : Array[String] = Array::new()
      let values : Array[Double] = Array::new()
      let counts : Array[Int] = Array::new()
      let mut skipped_rows = 0
      for row in table.rows {
        let raw_label = chart_cell_at(row, label_index)
        let label = if chart_trim_ascii(raw_label).is_empty() {
          "(blank)"
        } else {
          raw_label
        }
        match chart_parse_double(chart_cell_at(row, value_index)) {
          Some(value) =>
            match chart_index_of(labels, label) {
              Some(index) => {
                values[index] += value
                counts[index] += 1
              }
              None => {
                labels.push(label)
                values.push(value)
                counts.push(1)
              }
            }
          None => skipped_rows += 1
        }
      }
      if skipped_rows > 0 {
        warnings.push(
          "\{skipped_rows} row(s) were skipped because `\{value_column}` was empty or non-numeric.",
        )
      }
      chart_points_from_arrays(points, labels, values, counts, 24)
      {
        kind: normalized,
        title: "sum of \{value_column} by \{label_column}",
        label_column,
        value_column,
        measure: "sum",
        points,
        skipped_rows,
        warnings,
      }
    }
    (None, _) => {
      warnings.push("Label column `\{label_column}` was not found.")
      {
        kind: normalized,
        title: "chart preview",
        label_column,
        value_column,
        measure: "sum",
        points,
        skipped_rows: table.rows.length(),
        warnings,
      }
    }
    (_, None) => {
      warnings.push("Value column `\{value_column}` was not found.")
      {
        kind: normalized,
        title: "chart preview",
        label_column,
        value_column,
        measure: "sum",
        points,
        skipped_rows: table.rows.length(),
        warnings,
      }
    }
  }
}

///|
/// Build a category-count chart when no numeric column is available.
pub fn chart_count_spec(
  table : CsvTable,
  kind : String,
  label_column : String,
) -> CsvChartSpec {
  let normalized = chart_normalize_kind(kind)
  let warnings : Array[String] = Array::new()
  let points : Array[CsvChartPoint] = Array::new()
  match chart_column_index(table.headers, label_column) {
    Some(label_index) => {
      let labels : Array[String] = Array::new()
      let values : Array[Double] = Array::new()
      let counts : Array[Int] = Array::new()
      for row in table.rows {
        let raw_label = chart_cell_at(row, label_index)
        let label = if chart_trim_ascii(raw_label).is_empty() {
          "(blank)"
        } else {
          raw_label
        }
        match chart_index_of(labels, label) {
          Some(index) => {
            values[index] += 1.0
            counts[index] += 1
          }
          None => {
            labels.push(label)
            values.push(1.0)
            counts.push(1)
          }
        }
      }
      warnings.push(
        "No numeric column was found; generated a category-count chart.",
      )
      chart_points_from_arrays(points, labels, values, counts, 24)
      {
        kind: normalized,
        title: "count by \{label_column}",
        label_column,
        value_column: "count",
        measure: "count",
        points,
        skipped_rows: 0,
        warnings,
      }
    }
    None => {
      warnings.push("No chartable columns were found.")
      {
        kind: normalized,
        title: "chart preview",
        label_column,
        value_column: "count",
        measure: "count",
        points,
        skipped_rows: table.rows.length(),
        warnings,
      }
    }
  }
}

///|
/// Render a chart spec as machine-readable JSON.
pub fn chart_spec_json(spec : CsvChartSpec) -> String {
  let out = StringBuilder()
  out.write_char('{')
  out.write_string("\"kind\":")
  chart_write_json_string(out, spec.kind)
  out.write_string(",\"title\":")
  chart_write_json_string(out, spec.title)
  out.write_string(",\"label_column\":")
  chart_write_json_string(out, spec.label_column)
  out.write_string(",\"value_column\":")
  chart_write_json_string(out, spec.value_column)
  out.write_string(",\"measure\":")
  chart_write_json_string(out, spec.measure)
  out.write_string(",\"skipped_rows\":\{spec.skipped_rows}")
  out.write_string(",\"points\":")
  chart_write_points_json(out, spec.points)
  out.write_string(",\"warnings\":")
  chart_write_string_array_json(out, spec.warnings)
  out.write_char('}')
  out.to_string()
}

///|
/// Render a chart spec as standalone SVG.
pub fn chart_spec_svg(spec : CsvChartSpec) -> String {
  if spec.kind == "line" {
    chart_line_svg(spec)
  } else if spec.kind == "pie" {
    chart_pie_svg(spec)
  } else {
    chart_bar_svg(spec)
  }
}

///|
/// Render a chart spec as a standalone HTML document.
pub fn chart_spec_html(spec : CsvChartSpec) -> String {
  let out = StringBuilder()
  out.write_string("\n\n\n")
  out.write_string("  \n")
  out.write_string("  ")
  chart_write_html_escaped(out, spec.title)
  out.write_string("\n")
  out.write_string("  \n\n\n")
  out.write_string("  

") chart_write_html_escaped(out, spec.title) out.write_string("

\n") out.write_string("

Kind: ") chart_write_html_escaped(out, spec.kind) out.write_string(" · Label: ") chart_write_html_escaped(out, spec.label_column) out.write_string(" · Value: ") chart_write_html_escaped(out, spec.value_column) out.write_string(" · Measure: ") chart_write_html_escaped(out, spec.measure) out.write_string("

\n") for warning in spec.warnings { out.write_string("

") chart_write_html_escaped(out, warning) out.write_string("

\n") } out.write_string(chart_spec_svg(spec)) out.write_string("\n \n") out.write_string(" \n") for point in spec.points { out.write_string(" \n", ) } out.write_string("
LabelValueRows
") chart_write_html_escaped(out, point.label) out.write_string( "\{point.value}\{point.count}
\n") out.write_string("\n") out.to_string() } ///| fn chart_bar_svg(spec : CsvChartSpec) -> String { let out = StringBuilder() chart_write_svg_start(out, spec) if spec.points.length() == 0 { chart_write_empty_svg(out) out.write_string("") return out.to_string() } let left = 70.0 let top = 54.0 let plot_width = 660.0 let plot_height = 250.0 let bottom = top + plot_height let min_value = chart_min_value(spec.points).unwrap_or(0.0) let max_value = chart_max_value(spec.points).unwrap_or(0.0) let y_min = if min_value > 0.0 { 0.0 } else { min_value } let y_max = if max_value < 0.0 { 0.0 } else { max_value } let zero_y = chart_scale_y(0.0, y_min, y_max, top, plot_height) let slot = plot_width / spec.points.length().to_double() let bar_width = chart_max_double(8.0, slot * 0.62) out.write_string( "", ) out.write_string( "", ) chart_write_axis_labels(out, spec, y_min, y_max, left, top, plot_height) for i in 0..") chart_write_xml_escaped(out, "\{point.label}: \{point.value}") out.write_string("") chart_write_x_label(out, point.label, x + bar_width / 2.0, bottom + 18.0) } out.write_string("") out.to_string() } ///| fn chart_line_svg(spec : CsvChartSpec) -> String { let out = StringBuilder() chart_write_svg_start(out, spec) if spec.points.length() == 0 { chart_write_empty_svg(out) out.write_string("") return out.to_string() } let left = 70.0 let top = 54.0 let plot_width = 660.0 let plot_height = 250.0 let bottom = top + plot_height let min_value = chart_min_value(spec.points).unwrap_or(0.0) let max_value = chart_max_value(spec.points).unwrap_or(0.0) let y_min = if min_value == max_value { min_value - 1.0 } else { min_value } let y_max = if min_value == max_value { max_value + 1.0 } else { max_value } out.write_string( "", ) out.write_string( "", ) chart_write_axis_labels(out, spec, y_min, y_max, left, top, plot_height) out.write_string( " 0 { out.write_char(' ') } let x = chart_line_x(i, spec.points.length(), left, plot_width) let y = chart_scale_y(spec.points[i].value, y_min, y_max, top, plot_height) out.write_string("\{x},\{y}") } out.write_string("\"/>") for i in 0..") chart_write_xml_escaped(out, "\{point.label}: \{point.value}") out.write_string("") chart_write_x_label(out, point.label, x, bottom + 18.0) } out.write_string("") out.to_string() } ///| fn chart_pie_svg(spec : CsvChartSpec) -> String { let out = StringBuilder() chart_write_svg_start(out, spec) let total = chart_positive_total(spec.points) if spec.points.length() == 0 || total <= 0.0 { chart_write_empty_svg(out) out.write_string("") return out.to_string() } let cx = 220.0 let cy = 190.0 let radius = 88.0 let circumference = 552.92 let mut offset = 0.0 out.write_string( "", ) for i in 0.. 0.0 { let dash = point.value / total * circumference let rest = circumference - dash out.write_string( "", ) chart_write_xml_escaped(out, "\{point.label}: \{point.value}") out.write_string("") offset += dash } } out.write_string( "", ) out.write_string( "", ) chart_write_xml_escaped(out, spec.measure) out.write_string("") out.write_string( "total \{total}", ) let legend_x = 390.0 let mut legend_y = 94.0 for i in 0..") out.write_string( "", ) chart_write_xml_escaped(out, "\{point.label}: \{point.value}") out.write_string("") legend_y += 22.0 } out.write_string("") out.to_string() } ///| fn chart_write_svg_start(out : StringBuilder, spec : CsvChartSpec) -> Unit { out.write_string( "") out.write_string("") out.write_string( "", ) chart_write_xml_escaped(out, spec.title) out.write_string("") out.write_string("") chart_write_xml_escaped( out, "\{spec.kind} / \{spec.measure} / \{spec.points.length()} point(s)", ) out.write_string("") } ///| fn chart_write_empty_svg(out : StringBuilder) -> Unit { out.write_string( "No chartable data", ) } ///| fn chart_write_axis_labels( out : StringBuilder, spec : CsvChartSpec, y_min : Double, y_max : Double, left : Double, top : Double, plot_height : Double, ) -> Unit { out.write_string( "", ) chart_write_xml_escaped(out, y_max.to_string()) out.write_string("") out.write_string( "", ) chart_write_xml_escaped(out, y_min.to_string()) out.write_string("") out.write_string( "", ) chart_write_xml_escaped(out, spec.label_column) out.write_string("") } ///| fn chart_write_x_label( out : StringBuilder, label : String, x : Double, y : Double, ) -> Unit { out.write_string( "", ) chart_write_xml_escaped(out, chart_label_short(label, 12)) out.write_string("") } ///| fn chart_line_x( index : Int, count : Int, left : Double, width : Double, ) -> Double { if count <= 1 { left + width / 2.0 } else { left + index.to_double() * width / (count - 1).to_double() } } ///| fn chart_scale_y( value : Double, min_value : Double, max_value : Double, top : Double, height : Double, ) -> Double { if max_value == min_value { top + height / 2.0 } else { top + (max_value - value) / (max_value - min_value) * height } } ///| fn chart_pick_label_column(table : CsvTable) -> String { if table.headers.length() == 0 { return "row" } let profiles = profile_table(table) for profile in profiles { match profile.inferred { TextColumn | BooleanColumn => if profile.non_empty > 0 && profile.unique < profile.non_empty { return profile.name } _ => () } } for profile in profiles { match profile.inferred { TextColumn | BooleanColumn => return profile.name _ => () } } table.headers[0] } ///| fn chart_pick_value_column(table : CsvTable) -> String? { let profiles = profile_table(table) for profile in profiles { match profile.inferred { IntegerColumn | FloatColumn => if profile.non_empty > 0 { return Some(profile.name) } _ => () } } None } ///| fn chart_points_from_arrays( points : Array[CsvChartPoint], labels : Array[String], values : Array[Double], counts : Array[Int], limit : Int, ) -> Unit { let max_count = if labels.length() < limit { labels.length() } else { limit } for i in 0.. Unit { out.write_char('[') for i in 0.. 0 { out.write_char(',') } let point = points[i] out.write_char('{') out.write_string("\"label\":") chart_write_json_string(out, point.label) out.write_string(",\"value\":\{point.value}") out.write_string(",\"count\":\{point.count}") out.write_char('}') } out.write_char(']') } ///| fn chart_write_string_array_json( out : StringBuilder, values : Array[String], ) -> Unit { out.write_char('[') for i in 0.. 0 { out.write_char(',') } chart_write_json_string(out, values[i]) } out.write_char(']') } ///| fn chart_write_json_string(out : StringBuilder, value : String) -> Unit { out.write_char('"') chart_write_json_escaped(out, value) out.write_char('"') } ///| fn chart_write_json_escaped(out : StringBuilder, value : String) -> Unit { for ch in value.iter() { if ch == '"' { out.write_string("\\\"") } else if ch == '\\' { out.write_string("\\\\") } else if ch == '\n' { out.write_string("\\n") } else if ch == '\r' { out.write_string("\\r") } else if ch == '\t' { out.write_string("\\t") } else { out.write_char(ch) } } } ///| fn chart_write_html_escaped(out : StringBuilder, value : String) -> Unit { for ch in value.iter() { if ch == '&' { out.write_string("&") } else if ch == '<' { out.write_string("<") } else if ch == '>' { out.write_string(">") } else if ch == '"' { out.write_string(""") } else if ch == '\'' { out.write_string("'") } else { out.write_char(ch) } } } ///| fn chart_write_xml_escaped(out : StringBuilder, value : String) -> Unit { chart_write_html_escaped(out, value) } ///| fn chart_column_index(headers : Array[String], column : String) -> Int? { for i in 0.. String { if index >= 0 && index < row.length() { row[index] } else { "" } } ///| fn chart_index_of(values : Array[String], value : String) -> Int? { for i in 0.. Double? { try @string.parse_double(chart_trim_ascii(value)) catch { _ => None } noraise { number => Some(number) } } ///| fn chart_min_value(points : Array[CsvChartPoint]) -> Double? { if points.length() == 0 { return None } let mut value = points[0].value for i in 1.. Double? { if points.length() == 0 { return None } let mut value = points[0].value for i in 1.. value { value = points[i].value } } Some(value) } ///| fn chart_positive_total(points : Array[CsvChartPoint]) -> Double { let mut total = 0.0 for point in points { if point.value > 0.0 { total += point.value } } total } ///| fn chart_abs_double(value : Double) -> Double { if value < 0.0 { -value } else { value } } ///| fn chart_max_double(left : Double, right : Double) -> Double { if left > right { left } else { right } } ///| fn chart_color(index : Int) -> String { let colors = [ "#2563eb", "#0f766e", "#b45309", "#b91c1c", "#7c3aed", "#0369a1", "#15803d", "#c2410c", "#be123c", "#4338ca", ] colors[index % colors.length()] } ///| fn chart_label_short(value : String, limit : Int) -> String { let out = StringBuilder() let mut count = 0 for ch in value.iter() { if count >= limit { out.write_string("...") return out.to_string() } out.write_char(ch) count += 1 } out.to_string() } ///| fn chart_normalize_kind(kind : String) -> String { match kind.to_lower() { "line" => "line" "pie" | "donut" => "pie" _ => "bar" } } ///| fn chart_trim_ascii(value : String) -> String { let chars : Array[Char] = Array::new() for ch in value.iter() { chars.push(ch) } let mut start = 0 let mut finish = chars.length() while start < finish && chart_is_ascii_space(chars[start]) { start += 1 } while finish > start && chart_is_ascii_space(chars[finish - 1]) { finish -= 1 } let out = StringBuilder() for i in start.. Bool { ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' }