openapi: 3.0.3
info:
  title: Perscient viz-api
  version: '1.1.0'
  contact:
    name: Perscient
    email: sales@perscient.com
  description: |
    Headless renderer for Perscient signature charts. One endpoint,
    `POST /api/render`, returns an SVG document or a rasterized PNG.

    Two kinds of request:

    - **Row-based (`vizData`)** — you post the rows, the service renders
      them through the same Vega-Lite spec builders the Perscient front ends
      use. Unchanged from the previous version.
    - **Set-driven (`chart`)** — new in 1.1. The service resolves a CMS
      **signature set** (or a series named by uuid) itself, through the same
      data layer the sites' `/api/signature-sets` routes use, and draws it
      with the page's own chart code. The landscape stain and the stated
      index chart come out pixel-identical to the blocks on the page.

    ## Authentication

    Every request carries the shared secret in the `x-viz-api-secret`
    header. There are no per-caller credentials and no session or module
    policy: anyone holding the secret can render any set. Never call the
    service from a browser; proxy it from your own server.

    ## Sizing

    `presentation.width` is the output width in pixels. Vega charts derive
    their height from `presentation.aspectRatio` (width ÷ height). The
    SVG-native set charts (`stain`, `chart`) and the row-based
    `narrative_stain` types size themselves to their rows and ignore the
    aspect ratio.

    ## Fonts

    PNGs are rasterized with the bundled faces (see `presentation.font`).
    An SVG only names the face; the set-driven figures also carry a Google
    Fonts `@import` so a browser fetches it, Vega SVGs do not.
servers:
  - url: https://viz-api.perscient.net
    description: Production
  - url: http://localhost:4010
    description: Local development
security:
  - vizApiSecret: []
tags:
  - name: render
    description: Chart rendering
paths:
  /api/render:
    post:
      tags: [render]
      operationId: render
      summary: Render a chart as SVG or PNG
      description: |
        Send either a `vizData` request (rows you already have) or a `chart`
        request (a signature set or series the service resolves). The two
        shapes are distinguished by the presence of `chart`.

        The set-driven charts:

        - **`area`** — the set's re-baselined narrative index, one series per
          member (Vega area chart). Needs `set`.
        - **`diff`** — two series with the Net Line = A − B (Vega area-diff).
          Needs `query.uuid` and `query.pair`.
        - **`pie`** — one series' distribution over the window (Vega pie).
          Needs `query.uuid` and `query.slice`.
        - **`stain`** — the landscape stain: today's tick against each series'
          own history, coloured by state, with the reading from four weeks ago
          in grey. Needs `set`.
        - **`chart`** — the stated index chart: area, confirmed-regime
          shading, state ribbon, readout. Needs `query.uuid`.

        `query` carries the signature API's own parameters verbatim, so a
        placement's URL state (`?since=2y&d=…`) replays exactly. The window
        resolves the way the sites resolve it: the set's default, then the
        `window` / `start` / `res` overrides, then `since` wins.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/VizDataRequest'
                - $ref: '#/components/schemas/SetRequest'
            examples:
              rowsAreaChart:
                summary: Row-based area chart
                value:
                  vizData:
                    control:
                      id: demo
                      type: area_chart
                      date_column: date
                      value_column: value
                      label_column: label
                      stacked: false
                      cfg: { font: Barlow Semi Condensed, colorScheme: category20, dark: false }
                    data:
                      - { date: '2026-01-05', label: Alpha, value: 12.4 }
                      - { date: '2026-01-12', label: Alpha, value: 18.9 }
                      - { date: '2026-01-05', label: Beta, value: -4.1 }
                      - { date: '2026-01-12', label: Beta, value: 2.6 }
                  presentation: { format: png, width: 1600, aspectRatio: 1.8, title: Weekly signals }
              setStain:
                summary: A storyboard's landscape stain, last two years
                value:
                  chart: stain
                  set: set-5372359d19ed
                  query: { since: 2y }
                  options: { colorBy: regime }
                  presentation: { format: png, width: 1600 }
              seriesChart:
                summary: One signature's stated index chart, dark
                value:
                  chart: chart
                  query: { uuid: 30580a77-5e4f-5788-935c-b3bd4b06152e, since: 5y }
                  presentation: { format: svg, width: 1600, dark: true }
              pairDiff:
                summary: Two signatures with the Net Line
                value:
                  chart: diff
                  query:
                    uuid: 30580a77-5e4f-5788-935c-b3bd4b06152e
                    pair: b1660ce4-7d35-53ef-95ce-74c8b2a5f6d0
                    since: 2y
                  presentation: { format: png, width: 1600, aspectRatio: 2 }
              pie:
                summary: A series' distribution over the last year by vendor
                value:
                  chart: pie
                  query: { uuid: 46b6f927-1977-57ac-9413-6af986002141, slice: 'dimension:vendor', window: 1y }
                  presentation: { format: png, width: 1000, aspectRatio: 1.4 }
      responses:
        '200':
          description: The rendered image
          content:
            image/png:
              schema:
                type: string
                format: binary
            image/svg+xml:
              schema:
                type: string
                description: An SVG document with `width`, `height` and `viewBox` set
        '400':
          description: |
            Malformed request — missing `presentation.format` / `width`, an
            unknown `chart`, a set-driven request without its subject (`set`
            or `query.uuid`), no `aspectRatio` on a Vega chart, or an
            unsupported `vizData.control.type` (`heatmap`). Plain-text body.
          content:
            text/plain:
              schema: { type: string }
              example: area needs a signature set (`set`)
        '401':
          description: Missing or wrong `x-viz-api-secret`
          content:
            text/plain:
              schema: { type: string }
              example: Unauthorized
        '404':
          description: |
            Set-driven only — the set or series does not exist, or has no
            index history over the requested window. Plain-text body.
          content:
            text/plain:
              schema: { type: string }
              example: 'No such signature set: 999999'
        '500':
          description: |
            Render failure (`Render failed`, `PNG render failed`), or the
            service is missing configuration — `Server misconfigured` when
            the secret is unset; set-driven requests also fail here when the
            database or warehouse env is absent.
          content:
            text/plain:
              schema: { type: string }
components:
  securitySchemes:
    vizApiSecret:
      type: apiKey
      in: header
      name: x-viz-api-secret
      description: The deployment's shared secret.
  schemas:
    Presentation:
      type: object
      required: [format, width]
      properties:
        format:
          type: string
          enum: [svg, png]
        width:
          type: integer
          minimum: 1
          description: Output width in pixels.
        aspectRatio:
          type: number
          exclusiveMinimum: 0
          description: |
            width ÷ height. Required for every `vizData` request and for the
            `area`, `diff` and `pie` set charts; ignored by `stain`, `chart`
            and the row-based `narrative_stain` types, which size themselves.
        dark:
          type: boolean
          description: Dark palette (overrides `cfg.dark`).
        font:
          type: string
          description: |
            Family name or short code. Bundled: Raleway (`r`), IBM Plex Mono
            (`ibm`), Inter (`i`), Barlow Semi Condensed (`b`), Roboto (`ro`),
            Open Sans (`os`), Montserrat (`m`), Lato (`l`), Poppins (`p`),
            Source Sans Pro (`ssp`). Overrides `cfg.font`.
        colorScheme:
          type: string
          description: |
            Vega scheme for area / diff / pie: `category20` (default, `c20`),
            `tableau10` (`t10`), `set1`, `set2`, `dark2`, `paired`.
        title:
          type: string
          description: Baked into the image. Set charts default to the set's or series' name.
        attribution:
          type: boolean
          default: true
          description: Append the "source — perscient.com" footer.
        jitterSeed:
          type: integer
          default: 1
          description: Row-based `narrative_stain` only; deterministic jitter.
    VizControl:
      type: object
      required: [id, type]
      properties:
        id: { type: string }
        type:
          type: string
          enum: [narrative_stain, narrative_stain_sqrt, area_chart, area_diff, bar_split, distribution_pie]
          description: '`heatmap` is a React-only component and returns 400.'
        date_column:
          type: string
          description: Column hint, matched case-insensitively; omitted, the resolver probes `date` / `value` / `label`.
        value_column:
          type: string
          description: '`area_diff` accepts `"primary,combo"` for a combo-line overlay.'
        label_column: { type: string }
        stacked:
          type: boolean
          description: '`area_chart` only. The spec stacks unless this is `false`; a normalized index goes negative and must not be stacked.'
        netLineMode:
          type: string
          enum: [diff_first_two]
          description: '`area_diff` only: overlay Net Line = series[0] − series[1].'
        cfg:
          type: object
          properties:
            height: { type: number, description: Vega plot-area height before title, legend and attribution. }
            font: { type: string }
            colorScheme: { type: string }
            dark: { type: boolean }
    VizData:
      type: object
      required: [control, data]
      properties:
        control:
          $ref: '#/components/schemas/VizControl'
        data:
          type: array
          description: Flat rows — a date, a label and one (or two) numeric values. `distribution_pie` rows carry label + value only.
          items:
            type: object
            additionalProperties: true
    VizDataRequest:
      type: object
      title: Row-based request
      required: [vizData, presentation]
      properties:
        vizData:
          $ref: '#/components/schemas/VizData'
        presentation:
          $ref: '#/components/schemas/Presentation'
    SetChartOptions:
      type: object
      properties:
        stacked:
          type: boolean
          default: false
          description: '`area`: stack the areas. Off by default — the index goes negative.'
        colorBy:
          type: string
          enum: [none, regime, event]
          default: regime
          description: '`stain`: colour today''s tick by a state family, or by nothing for a plain distribution stain.'
        sort:
          type: string
          enum: [set, escalation, name, index]
          default: set
          description: '`stain`: row order. `set` is the order the signature set lists them.'
        limit:
          type: integer
          minimum: 1
          description: '`stain`: draw at most this many rows after sorting.'
        ribbon:
          type: boolean
          default: true
          description: '`chart`: the Event / Regime state ribbon under the plot.'
        readout:
          type: boolean
          default: true
          description: '`chart`: the four-line Tendency / Event / Regime / Index readout.'
        markers:
          type: boolean
          default: false
          description: '`chart`: dashed markers at confirmed regime change-points.'
    SetQuery:
      type: object
      description: |
        The signature API's own query parameters, verbatim.

        Set charts (`area`, `stain`): `d` (dimensions JSON, replacing the
        set's by key; a key may hold a list of values meaning any of them), `general=1` (undimensioned series only), `sigs` (a
        comma-separated allowlist of signature uuids — a block's picks, in
        drawing order), `sig` (one signature uuid, intersecting `sigs`), `window` (`90d|1y|2y|5y|10y|all|custom`), `start`
        (YYYY-MM-DD, with `window=custom`), `res` (`weekly|daily`), `since`
        (`1y`, `2y`, `5y` or a date — wins over the rest).

        Series charts: `uuid` (+ `d` for a dimensioned series); `pair` (+
        `pd`) for `diff`; `slice` (`dimension:<key>` or `cell:<key>`) and
        `vals` (comma-separated allowlist) for `pie`; the same window params.
      additionalProperties:
        oneOf:
          - { type: string }
          - { type: number }
          - { type: boolean }
      example: { since: 2y, d: '{"country":"US"}' }
    SetRequest:
      type: object
      title: Set-driven request
      required: [chart, presentation]
      properties:
        chart:
          type: string
          enum: [area, diff, pie, stain, chart]
        set:
          oneOf:
            - { type: integer }
            - { type: string }
          description: Signature set id or `set-…` slug. Required for `area` and `stain`.
        query:
          $ref: '#/components/schemas/SetQuery'
        options:
          $ref: '#/components/schemas/SetChartOptions'
        presentation:
          $ref: '#/components/schemas/Presentation'
