@baravak/risloo-profile-cli 4.86.0 → 4.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baravak/risloo-profile-cli",
3
- "version": "4.86.0",
3
+ "version": "4.88.0",
4
4
  "description": "**Risloo Profile CLI** is a library for creating profiles, reports and sheets for *psychological* samples.",
5
5
  "main": "bin/risloo.js",
6
6
  "publishConfig": {
@@ -1,25 +1,25 @@
1
1
  const { Profile } = require("../Profile");
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
- // AIS93 — مقیاس بی‌خوابی آتن (AIS-8 فارسی). No Figma source: the chart is
5
- // designed from the approved individual-result specification of the science
6
- // package (AIS-FA8-C2), whose rules this layout enforces rather than decorates:
7
- // - one score only, the 0–24 total, drawn as its position on one line with
8
- // that range; no night/day subscore, no component, no comparison;
9
- // - classification is off: one neutral hue, no coloured zone, no threshold,
10
- // no label, no warning, no percentile or norm;
11
- // - the only interpretation is the two approved sentences, verbatim;
12
- // - the reference cut-off (6) is text only, with its source, sample and year,
13
- // because the profile is read by the authorised specialist and never handed
14
- // to the respondent; the score is never compared with it;
15
- // - all-or-nothing: unless scoring/AIS93.py returns status "valid", only the
16
- // no-result message is drawn — never a partial or zero score.
17
- // Chart 863 × 674, centred on the 943 × 754 design page.
4
+ // AIS93 — مقیاس بی‌خوابی آتن (AIS-8 فارسی). Chart drawn from the Figma
5
+ // half-ring gauge (Assessments v3.0.0, node 5405:146150): the total (0–24) as
6
+ // a 180° gauge filling clockwise from 0 on the left to 100 % on the right,
7
+ // percentage and "mark از max" in the centre, title above.
8
+ // The interpretation panel and the reference cut-off text were dropped on
9
+ // request to match that design.
10
+ // All-or-nothing: unless scoring/AIS93.py returns status "valid", only the
11
+ // no-result message is drawn — never a partial or zero score.
12
+ // The gauge piece sits at (346, 284) on the 943 × 754 design page, one pixel
13
+ // off-centre on each axis (insets 346 / 347 and 284 / 283), so the Chart is
14
+ // the page minus the measured left/top insets: 251 × 186. The piece is 250
15
+ // wide; its bottom labels paint down to y ≈ 186.
18
16
  // ---------------------------------------------------------------------------
19
- const CHART = { width: 863, height: 674 };
17
+ const CHART = { width: 251, height: 186 };
18
+ const INSET = { x: 346, y: 284 }; // measured on the design page
20
19
 
21
- // The total's own axis: 0 at x=LINE.left, 24 at x=LINE.right.
22
- const LINE = { left: 20, right: 640, y: 112 };
20
+ // Gauge centre on the Chart; R 125 / r 87.5, so the ring's top sits at y = 42.
21
+ const GAUGE = { x: 125, y: 167 };
22
+ const SWEEP = { start: Math.PI, end: 2 * Math.PI }; // left → top → right, clockwise
23
23
 
24
24
  class AIS93 extends Profile {
25
25
  static pages = 1;
@@ -40,15 +40,15 @@ class AIS93 extends Profile {
40
40
  profile: {
41
41
  get dimensions() {
42
42
  return {
43
- width: CHART.width + 2 * this.padding.x, // 863 → 903
44
- height: CHART.height + 2 * this.padding.y, // 674 → 714
43
+ width: CHART.width + 2 * this.padding.x, // 251 → 903
44
+ height: CHART.height + 2 * this.padding.y, // 186 → 714
45
45
  };
46
46
  },
47
- // Chart inset inside the 943 × 754 design page minus the 20 px the layout
48
- // owns on every side, so the with-sidebar page renders at scale 1.
47
+ // Measured Chart inset on the 943 × 754 design page minus the 20 px the
48
+ // layout owns on every side, so the with-sidebar page renders at scale 1.
49
49
  padding: {
50
- x: (943 - CHART.width) / 2 - 20, // 20
51
- y: (754 - CHART.height) / 2 - 20, // 20
50
+ x: INSET.x - 20, // 326
51
+ y: INSET.y - 20, // 264
52
52
  },
53
53
  },
54
54
  labels: Object.values(this.labels),
@@ -68,17 +68,19 @@ class AIS93 extends Profile {
68
68
 
69
69
  const mark = s[0].mark ?? 0;
70
70
  const max = s[0].label.max;
71
+ const p = Math.min(Math.max(mark / max, 0), 1);
71
72
 
72
73
  return [
73
74
  {
74
75
  valid: true,
75
76
  total: {
77
+ title: "بی‌خوابی آتن",
76
78
  mark,
77
79
  max,
78
- left: LINE.left,
79
- right: LINE.right,
80
- y: LINE.y,
81
- x: LINE.left + ((LINE.right - LINE.left) * mark) / max,
80
+ pct: Math.round(p * 100),
81
+ zeta: SWEEP.start + p * (SWEEP.end - SWEEP.start),
82
+ x: GAUGE.x,
83
+ y: GAUGE.y,
82
84
  },
83
85
  },
84
86
  ];
@@ -1,28 +1,29 @@
1
1
  const { Profile } = require("../Profile");
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
- // PSQI93 — شاخص کیفیت خواب پیتزبورگ. No Figma source: the chart was designed
5
- // directly from the approved result specification of the science record
6
- // (catalog/assessments/psqi, «مشخصات نتیجه فردی»), whose rules this layout
7
- // enforces rather than decorates:
4
+ // PSQI93 — شاخص کیفیت خواب پیتزبورگ. Layout from the Figma "Chart" layer of
5
+ // the JSS93 design (Assessments v3.0.0, node 8071:183989), adapted to seven
6
+ // rows on a 0–3 axis with no raw-score column, and a vertical 0–21 total
7
+ // column. Rules kept from the approved result specification of the science
8
+ // record (catalog/assessments/psqi, «مشخصات نتیجه فردی»):
8
9
  // - seven components on one shared 0–3 axis, raw only — no per-component
9
10
  // class, colour or label (no evidence for any);
10
- // - the total 0–21, drawn neutral: the screening result lives ONLY in the
11
- // approved interpretation text — no badge, no colour, no cut line;
11
+ // - the total 0–21 with the screening cut line at 6: 0–5 neutral, 6–21 red;
12
12
  // - all-or-nothing: when scoring/PSQI93.py returns status "invalid" nothing
13
13
  // but the no-result message is drawn — never a partial or zero score.
14
- // Chart 863 × 674, centred on the 943 × 754 design page.
14
+ // Chart 789 × 334: the design's 789 × 414 less two of its 40 px rows, centred
15
+ // on the 943 × 754 design page.
15
16
  // ---------------------------------------------------------------------------
16
- const CHART = { width: 863, height: 674 };
17
+ const CHART = { width: 789, height: 334 };
17
18
 
18
- // Component rows: bars grow left → right from the 0 guide; the total card sits
19
- // to the right of the chart.
20
- const ROW = { top: 84, pitch: 44, height: 18 };
21
- const AXIS = { zero: 162, unit: 150, max: 3 }; // 0 at x=162, 3 at x=612
22
- const BASELINE = 13.2; // 14 px text on an 18 px row
19
+ // Component rows keep the design's 40 px pitch and row mid-line (top + 8); the
20
+ // bar is 22 px, 3 px taller than the design's 16 px on each side.
21
+ const ROW = { top: 54, pitch: 40, height: 22, inset: -3 };
22
+ const AXIS = { zero: 174, unit: 400 / 3, max: 3, gap: 6 }; // 0 at x=174, 3 at x=574; score 6 px inside the tip
23
+ const BASELINE = 12.06; // 13 px text on the row mid-line, from the row top
23
24
 
24
- // Total card bar, also left → right: 0 at the card's left inset, 21 at its right.
25
- const TOTAL = { left: 713, length: 130, max: 21, cutoff: 6 };
25
+ // Total column: vertical bar filling bottom → up, 0 at y=316, 21 at y=46.
26
+ const TOTAL = { bottom: 316, length: 270, max: 21, cutoff: 6 };
26
27
 
27
28
  // Interpretation panel: one fixed line pitch; the conditional false-positive
28
29
  // line collapses its space when the screen is negative.
@@ -55,15 +56,15 @@ class PSQI93 extends Profile {
55
56
  profile: {
56
57
  get dimensions() {
57
58
  return {
58
- width: CHART.width + 2 * this.padding.x, // 863 → 903
59
- height: CHART.height + 2 * this.padding.y, // 674 → 714
59
+ width: CHART.width + 2 * this.padding.x, // 789 → 903
60
+ height: CHART.height + 2 * this.padding.y, // 334 → 714
60
61
  };
61
62
  },
62
63
  // Chart inset inside the 943 × 754 design page minus the 20 px the layout
63
64
  // owns on every side, so the with-sidebar page renders at scale 1.
64
65
  padding: {
65
- x: (943 - CHART.width) / 2 - 20, // 20
66
- y: (754 - CHART.height) / 2 - 20, // 20
66
+ x: (943 - CHART.width) / 2 - 20, // 57
67
+ y: (754 - CHART.height) / 2 - 20, // 190
67
68
  },
68
69
  },
69
70
  labels: Object.values(this.labels),
@@ -90,15 +91,18 @@ class PSQI93 extends Profile {
90
91
  title: data.label.title,
91
92
  mark,
92
93
  top,
94
+ barTop: top + ROW.inset,
93
95
  width,
94
96
  x: AXIS.zero,
97
+ labelX: AXIS.zero + width - AXIS.gap,
95
98
  baseline: top + BASELINE,
96
99
  };
97
100
  });
98
101
 
99
102
  const totalMark = s[7].mark ?? 0;
100
103
  const positive = totalMark >= TOTAL.cutoff;
101
- const totalWidth = (TOTAL.length * totalMark) / TOTAL.max;
104
+ const totalHeight = (TOTAL.length * totalMark) / TOTAL.max;
105
+ const totalTip = TOTAL.bottom - totalHeight;
102
106
 
103
107
  const line = (n) => TEXT.top + n * TEXT.pitch;
104
108
  const falsePositive = positive ? line(3) : null;
@@ -112,8 +116,12 @@ class PSQI93 extends Profile {
112
116
  total: {
113
117
  mark: totalMark,
114
118
  max: TOTAL.max,
115
- width: totalWidth,
116
- x: TOTAL.left,
119
+ height: totalHeight,
120
+ tip: totalTip,
121
+ // 18 px number centred on the tip of the bar, travelling with it.
122
+ baseline: totalTip + 6.5,
123
+ // Screening cut: a total of exactly 6 puts the tip on this line.
124
+ cut: TOTAL.bottom - (TOTAL.length * TOTAL.cutoff) / TOTAL.max,
117
125
  },
118
126
  screen: {
119
127
  positive,
@@ -1,60 +1,48 @@
1
1
  {{#> layout}}
2
2
 
3
+ <defs>
4
+ {{#if valid}}
5
+ {{! Fill gradient from the Figma export, in gauge-centred user space (page 346,451 → 555.826,359.187 about centre 471,451) }}
6
+ <linearGradient id="aisGauge" x1="-125" y1="0" x2="84.826" y2="-91.813" gradientUnits="userSpaceOnUse">
7
+ <stop stop-color="#818CF8"/>
8
+ <stop offset="1" stop-color="#4338CA"/>
9
+ </linearGradient>
10
+ {{! Track shape: both ends rounded; clips the fill so a full gauge keeps the rounded right end }}
11
+ <clipPath id="aisGaugeTrack">
12
+ {{gauge 125 87.5 (object tl=6 tr=6 bl=6 br=6) (object start=(toRad 180) end=(toRad 0)) false}}
13
+ </clipPath>
14
+ {{/if}}
15
+ </defs>
16
+
3
17
  <g transform="translate({{spec.profile.padding.x}}, {{spec.profile.padding.y}})">
4
18
 
5
19
  {{#if valid}}
6
20
 
7
- {{! ===================== The total, on its own 0–24 line ===================== }}
8
- {{! One neutral hue; no zone, no threshold, no tick at the reference cut-off. }}
9
- <text x="843" y="24" text-anchor="start" font-size="16" font-weight="600" fill="#1E293B">نمرهٔ کل</text>
10
-
11
- <line x1="{{total.left}}" y1="{{total.y}}" x2="{{total.right}}" y2="{{total.y}}" stroke="#CBD5E1" stroke-width="4" stroke-linecap="round"/>
12
- <line x1="{{total.left}}" y1="{{math total.y '-' 7}}" x2="{{total.left}}" y2="{{math total.y '+' 7}}" stroke="#94A3B8" stroke-width="1.5"/>
13
- <line x1="{{total.right}}" y1="{{math total.y '-' 7}}" x2="{{total.right}}" y2="{{math total.y '+' 7}}" stroke="#94A3B8" stroke-width="1.5"/>
14
- <text x="{{total.left}}" y="{{math total.y '+' 24}}" text-anchor="middle" font-size="11" fill="#94A3B8">0</text>
15
- <text x="{{total.right}}" y="{{math total.y '+' 24}}" text-anchor="middle" font-size="11" fill="#94A3B8">{{total.max}}</text>
16
-
17
- <circle cx="{{total.x}}" cy="{{total.y}}" r="7" fill="#475569" stroke="white" stroke-width="2"/>
18
- <text x="{{total.x}}" y="{{math total.y '-' 14}}" text-anchor="middle" font-size="13" font-weight="700" fill="#1E293B">{{total.mark}}</text>
19
-
20
- {{! Total card (right of the line) }}
21
- <rect x="693.5" y="52.5" width="169" height="120" rx="8" fill="#F8FAFC" stroke="#E2E8F0"/>
22
- <text x="778" y="82" text-anchor="middle" font-size="14" font-weight="600" fill="#475569">نمرهٔ کل</text>
23
- <text x="778" y="134" text-anchor="middle" font-size="44" font-weight="700" fill="#1E293B">{{total.mark}}</text>
24
- <text x="778" y="158" text-anchor="middle" font-size="13" fill="#64748B">از {{total.max}}</text>
25
-
26
- {{! ===================== Interpretation, uncertainty, reference cut-off ===================== }}
27
- {{! Fixed text from the science package; the profile is read by the authorised specialist. }}
28
- <rect x="0.5" y="204.5" width="862" height="401" rx="8" fill="#F8FAFC" stroke="#E2E8F0"/>
29
-
30
- <text x="843" y="236" text-anchor="start" font-size="15" font-weight="600" fill="#1E293B">تفسیر</text>
31
- <text x="843" y="264" text-anchor="start" font-size="13.5" fill="#334155">نمرهٔ بالاتر، دشواری بیشتری در خواب را نشان می‌دهد، همان‌طور که پاسخ‌دهنده خودش گزارش کرده است.</text>
32
- <text x="843" y="288" text-anchor="start" font-size="13.5" fill="#334155">این نمره تشخیص نیست.</text>
33
-
34
- <text x="843" y="326" text-anchor="start" font-size="15" font-weight="600" fill="#1E293B">عدم قطعیت و محدودیت</text>
35
- <circle cx="840" cy="349.5" r="2" fill="#334155"/>
36
- <text x="831" y="354" text-anchor="start" font-size="13.5" fill="#334155">این نسخهٔ فارسی اعتبارسنجی نشده و برش بومی ندارد.</text>
37
- <circle cx="840" cy="373.5" r="2" fill="#334155"/>
38
- <text x="831" y="378" text-anchor="start" font-size="13.5" fill="#334155">این ابزار غربال است، نه تشخیص.</text>
39
- <circle cx="840" cy="397.5" r="2" fill="#334155"/>
40
- <text x="831" y="402" text-anchor="start" font-size="13.5" fill="#334155">آزمون تصمیم‌یار است، نه تصمیم‌گیر قطعی.</text>
41
-
42
- <text x="843" y="440" text-anchor="start" font-size="15" font-weight="600" fill="#1E293B">نقطهٔ برش مرجع</text>
43
- <text x="843" y="468" text-anchor="start" font-size="13" fill="#334155">نمرهٔ کل <tspan font-weight="700">6</tspan> — فقط اطلاعات مرجع برای متخصص؛ نمرهٔ این پاسخ‌نامه با آن طبقه‌بندی نمی‌شود.</text>
44
- <text x="843" y="496" text-anchor="start" font-size="13" fill="#334155">منبع: مطالعهٔ روایی تشخیصیِ نسخهٔ یونانی، در برابر تشخیص بی‌خوابی غیرارگانیک در <tspan class="eng-font" font-family="Dana">ICD-10</tspan>.</text>
45
- <text x="843" y="520" text-anchor="start" font-size="13" fill="#334155">جامعه: نمونهٔ بالینی/مختلط آتن، 299 نفر.</text>
46
- <text x="843" y="544" text-anchor="start" font-size="13" fill="#334155">سال: 2003.</text>
47
- <text x="843" y="572" text-anchor="start" font-size="13" font-weight="600" fill="#334155">این عدد به نسخهٔ فارسی و به فرم پنج‌گویه‌ای تعلق ندارد.</text>
21
+ {{! ===================== Half-ring gauge (Figma node 5405:146150) ===================== }}
22
+ <g transform="translate({{total.x}}, {{total.y}})">
23
+ {{gauge 125 87.5 (object tl=6 tr=6 bl=6 br=6) (object start=(toRad 180) end=(toRad 0)) false fill="#F1F5F9"}}
24
+ {{#if total.mark}}{{gauge 125 87.5 (object tl=6 tr=0 bl=6 br=0) (object start=(toRad 180) end=total.zeta) false clip-path="url(#aisGaugeTrack)" fill="url(#aisGauge)"}}{{/if}}
25
+ </g>
26
+
27
+ <text x="124.5" y="18" text-anchor="middle" font-size="18" font-weight="500" fill="#4338CA">{{total.title}}</text>
28
+
29
+ <text x="125" y="141" text-anchor="middle" font-size="28" font-weight="600" fill="#4F46E5" direction="ltr">٪ {{total.pct}}</text>
30
+ <text x="125" y="166" text-anchor="middle" font-size="18" font-weight="500" fill="#64748B">{{total.mark}} از {{total.max}}</text>
31
+
32
+ <text x="18.5" y="186" text-anchor="middle" font-size="14" font-weight="500" fill="#64748B">0</text>
33
+ <text x="232.5" y="186" text-anchor="middle" font-size="14" font-weight="500" fill="#64748B" direction="ltr">٪ 100</text>
48
34
 
49
35
  {{else}}
50
36
 
51
- {{! ===================== No result (all-or-nothing) ===================== }}
52
- <rect x="181.5" y="237.5" width="500" height="199" rx="12" fill="#F8FAFC" stroke="#E2E8F0"/>
53
- <circle cx="431.5" cy="288" r="17" fill="none" stroke="#64748B" stroke-width="2"/>
54
- <line x1="431.5" y1="279" x2="431.5" y2="291" stroke="#64748B" stroke-width="2.5" stroke-linecap="round"/>
55
- <circle cx="431.5" cy="297.5" r="1.6" fill="#64748B"/>
56
- <text x="431.5" y="344" text-anchor="middle" font-size="17" font-weight="600" fill="#1E293B">نمره تولید نشد</text>
57
- <text x="431.5" y="376" text-anchor="middle" font-size="13" fill="#64748B">پاسخ ناقص یا نامعتبر است؛ هیچ نمره‌ای، حتی نمرهٔ جزئی، گزارش نمی‌شود.</text>
37
+ {{! ===================== No result (all-or-nothing), centred on the Chart ===================== }}
38
+ <g transform="translate(-306.5, -243.5)">
39
+ <rect x="181.5" y="237.5" width="500" height="199" rx="12" fill="#F8FAFC" stroke="#E2E8F0"/>
40
+ <circle cx="431.5" cy="288" r="17" fill="none" stroke="#64748B" stroke-width="2"/>
41
+ <line x1="431.5" y1="279" x2="431.5" y2="291" stroke="#64748B" stroke-width="2.5" stroke-linecap="round"/>
42
+ <circle cx="431.5" cy="297.5" r="1.6" fill="#64748B"/>
43
+ <text x="431.5" y="344" text-anchor="middle" font-size="17" font-weight="600" fill="#1E293B">نمره تولید نشد</text>
44
+ <text x="431.5" y="376" text-anchor="middle" font-size="13" fill="#64748B">پاسخ ناقص یا نامعتبر است؛ هیچ نمره‌ای، حتی نمرهٔ جزئی، گزارش نمی‌شود.</text>
45
+ </g>
58
46
 
59
47
  {{/if}}
60
48
 
@@ -1,107 +1,144 @@
1
1
  {{#> layout}}
2
2
 
3
3
  <defs>
4
- {{! Component bar: grows left → right, so the gradient runs from the 0 guide (light)
5
- to the tip (dark) in each bar's own bounding box. One hue for all seven — a
6
- per-component colour would read as a class the scoring does not define. }}
7
- <linearGradient id="psqiBar" x1="0" y1="0" x2="1" y2="0">
4
+ {{! Component bars, alternating indigo / violet by row: Figma anchors the gradient to
5
+ each bar's own width, so bounding-box units reproduce every bar from one definition. }}
6
+ <linearGradient id="psqiBarIndigo" x1="0" y1="0" x2="1" y2="0">
8
7
  <stop stop-color="#A5B4FC"/>
9
8
  <stop offset="1" stop-color="#4338CA"/>
10
9
  </linearGradient>
10
+ <linearGradient id="psqiBarViolet" x1="0" y1="0" x2="1" y2="0">
11
+ <stop stop-color="#C4B5FD"/>
12
+ <stop offset="1" stop-color="#6D28D9"/>
13
+ </linearGradient>
14
+
15
+ {{! Total bar: the gradient runs 194 % of the fill height up from the foot of the bar,
16
+ so the visible tip keeps the same tone at every score. }}
17
+ <linearGradient id="psqiTotal" x1="0" y1="1" x2="0" y2="-0.94">
18
+ <stop stop-color="#94A3B8"/>
19
+ <stop offset="1" stop-color="#334155"/>
20
+ </linearGradient>
21
+
22
+ {{! Same run in red, for a total at or above the screening cut }}
23
+ <linearGradient id="psqiTotalPositive" x1="0" y1="1" x2="0" y2="-0.94">
24
+ <stop stop-color="#FCA5A5"/>
25
+ <stop offset="1" stop-color="#B91C1C"/>
26
+ </linearGradient>
11
27
 
12
- {{! Total track, fully rounded; the fill is clipped to it so any width keeps the ends. }}
13
- <clipPath id="psqiTotalTrack">
14
- <rect x="713" y="260" width="130" height="10" rx="5"/>
15
- </clipPath>
28
+ {{! Shadow of the white 0 baseline (offset right, soft blur) }}
29
+ <filter id="psqiBaseShadow" x="171" y="27" width="7" height="310" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
30
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
31
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
32
+ <feOffset dx="1"/>
33
+ <feGaussianBlur stdDeviation="1.5"/>
34
+ <feComposite in2="hardAlpha" operator="out"/>
35
+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
36
+ <feBlend mode="normal" in2="BackgroundImageFix" result="effect1"/>
37
+ <feBlend mode="normal" in="SourceGraphic" in2="effect1" result="shape"/>
38
+ </filter>
39
+
40
+ {{! Same treatment under the foot of the total bar }}
41
+ <filter id="psqiTotalBaseShadow" x="711" y="310" width="34" height="14" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
42
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
43
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
44
+ <feOffset dx="2"/>
45
+ <feGaussianBlur stdDeviation="3"/>
46
+ <feComposite in2="hardAlpha" operator="out"/>
47
+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
48
+ <feBlend mode="normal" in2="BackgroundImageFix" result="effect1"/>
49
+ <feBlend mode="normal" in="SourceGraphic" in2="effect1" result="shape"/>
50
+ </filter>
16
51
  </defs>
17
52
 
18
53
  <g transform="translate({{spec.profile.padding.x}}, {{spec.profile.padding.y}})">
19
54
 
20
55
  {{#if valid}}
21
56
 
22
- {{! ===================== Components: heading (over the chart, right-aligned to it) ===================== }}
23
- <text x="660" y="24" text-anchor="start" font-size="16" font-weight="600" fill="#1E293B">مولفه‌های کیفیت خواب</text>
24
- <text x="660" y="46" text-anchor="start" font-size="12" fill="#64748B">نمره هر مولفه از 0 تا 3؛ نمره بالاتر یعنی مشکل بیشتر</text>
25
-
26
- {{! Axis values over the 0–3 guides, and the raw-score column header }}
57
+ {{! ===================== Gridlines (1, 2, 3) ===================== }}
27
58
  {{#each ticks as |tick|}}
28
- <text x="{{tick.x}}" y="72" text-anchor="middle" font-size="12" fill="#94A3B8">{{tick.v}}</text>
59
+ {{#if tick.v}}
60
+ <line x1="{{math tick.x '-' 0.5}}" y1="30.5" x2="{{math tick.x '-' 0.5}}" y2="333.5" stroke="#E2E8F0" stroke-linecap="round" stroke-dasharray="6 6"/>
61
+ {{/if}}
29
62
  {{/each}}
30
- <text x="644" y="72" text-anchor="middle" font-size="12" fill="#94A3B8">نمره</text>
31
63
 
32
- {{! ===================== Gridlines (1, 2, 3) ===================== }}
33
- <line x1="312.5" y1="78" x2="312.5" y2="366" stroke="#E2E8F0" stroke-linecap="round" stroke-dasharray="6 6"/>
34
- <line x1="462.5" y1="78" x2="462.5" y2="366" stroke="#E2E8F0" stroke-linecap="round" stroke-dasharray="6 6"/>
35
- <line x1="612.5" y1="78" x2="612.5" y2="366" stroke="#E2E8F0" stroke-linecap="round" stroke-dasharray="6 6"/>
36
-
37
- {{! ===================== Tracks ===================== }}
64
+ {{! ===================== Bar tracks ===================== }}
38
65
  {{#each items as |item|}}
39
- <g transform="translate(162, {{item.top}})">
40
- {{bar 450 18 (object tl=0 bl=0 tr=3 br=3) (toRad 0) fill="black" fill-opacity="0.04"}}
66
+ <g transform="translate(174, {{item.barTop}})">
67
+ {{bar 400 22 (object tl=0 bl=0 tr=3 br=3) (toRad 0) fill="black" fill-opacity="0.04"}}
41
68
  </g>
42
69
  {{/each}}
43
70
 
44
- {{! ===================== Bars ===================== }}
71
+ {{! ===================== Bars + score inside the tip (nothing for 0) ===================== }}
45
72
  {{#each items as |item|}}
46
73
  {{#if item.width}}
47
- <g transform="translate({{item.x}}, {{item.top}})">
48
- {{bar item.width 18 (object tl=0 bl=0 tr=3 br=3) (toRad 0) fill="url(#psqiBar)"}}
74
+ <g transform="translate(174, {{item.barTop}})">
75
+ {{bar item.width 22 (object tl=0 bl=0 tr=3 br=3) (toRad 0) fill=(ternary (math @index '%' 2) "url(#psqiBarViolet)" "url(#psqiBarIndigo)")}}
49
76
  </g>
77
+ {{! 15 px number centred on the row mid-line (top + 8) }}
78
+ <text x="{{item.labelX}}" y="{{math item.baseline '+' 0.59}}" text-anchor="end" direction="ltr" font-size="15" font-weight="600" fill="white">{{item.mark}}</text>
50
79
  {{/if}}
51
80
  {{/each}}
52
81
 
53
- {{! 0 guide }}
54
- <line x1="161.5" y1="78" x2="161.5" y2="366" stroke="#CBD5E1" stroke-linecap="round"/>
55
-
56
- {{! ===================== Component names (left of the 0 guide) and raw scores (after 3) ===================== }}
82
+ {{! ===================== Component names ===================== }}
57
83
  {{#each items as |item|}}
58
- <text x="150" y="{{item.baseline}}" text-anchor="start" font-size="14" fill="#334155">{{item.title}}</text>
59
- <rect x="628.5" y="{{math item.top '+' 0.5}}" width="31" height="17" rx="4" fill="white" stroke="#6366F1"/>
60
- <text x="644" y="{{item.baseline}}" text-anchor="middle" font-size="13" font-weight="600" fill="#3730A3">{{item.mark}}</text>
84
+ <text x="165" y="{{item.baseline}}" text-anchor="start" font-size="13" fill="#475569">{{item.title}}</text>
61
85
  {{/each}}
62
86
 
63
- {{! ===================== Total card (right of the chart) ===================== }}
64
- <rect x="693.5" y="78.5" width="169" height="287" rx="8" fill="#F8FAFC" stroke="#E2E8F0"/>
65
- <text x="778" y="136" text-anchor="middle" font-size="14" font-weight="600" fill="#475569">نمره کل</text>
66
- <text x="778" y="206" text-anchor="middle" font-size="48" font-weight="700" fill="#1E293B">{{total.mark}}</text>
67
- <text x="778" y="232" text-anchor="middle" font-size="13" fill="#64748B">از {{total.max}}</text>
87
+ {{! ===================== White shadowed 0 baseline ===================== }}
88
+ <g opacity="0.5" filter="url(#psqiBaseShadow)">
89
+ <line x1="173.5" y1="30.5" x2="173.5" y2="333.5" stroke="white" stroke-linecap="round"/>
90
+ </g>
91
+
92
+ {{! ===================== Axis labels, centred on their guides ===================== }}
93
+ {{#each ticks as |tick|}}
94
+ <text x="{{math tick.x '-' 0.5}}" y="18.06" text-anchor="middle" font-size="12" fill="#94A3B8">{{tick.v}}</text>
95
+ {{/each}}
68
96
 
69
- {{! Neutral 0–21 bar, filling left → right; deliberately no cut mark or colour change }}
70
- <rect x="713" y="260" width="130" height="10" rx="5" fill="#E2E8F0"/>
71
- {{#if total.width}}
72
- <rect x="{{total.x}}" y="260" width="{{total.width}}" height="10" fill="#475569" clip-path="url(#psqiTotalTrack)"/>
97
+ {{! ===================== Total score column ===================== }}
98
+ <g transform="translate(717, 46)">
99
+ {{bar 18 270 (object tl=2 tr=2 bl=0 br=0) (toRad 0) fill="#F1F5F9"}}
100
+ </g>
101
+ {{! Track above the cut tinted red: the 6–21 zone }}
102
+ <g transform="translate(717, 46)">
103
+ {{bar 18 (math total.cut '-' 46) (object tl=2 tr=2 bl=0 br=0) (toRad 0) fill="#FEF2F2"}}
104
+ </g>
105
+ {{#if total.height}}
106
+ <g transform="translate(717, {{total.tip}})">
107
+ {{bar 18 total.height (object tl=2 tr=2 bl=0 br=0) (toRad 0) fill=(ternary screen.positive "url(#psqiTotalPositive)" "url(#psqiTotal)")}}
108
+ </g>
73
109
  {{/if}}
74
- <text x="713" y="288" text-anchor="middle" font-size="11" fill="#94A3B8">0</text>
75
- <text x="843" y="288" text-anchor="middle" font-size="11" fill="#94A3B8">21</text>
76
- <text x="778" y="322" text-anchor="middle" font-size="12" fill="#94A3B8">جمع هفت مولفه</text>
77
110
 
78
- {{! ===================== Interpretation panel ===================== }}
79
- {{! Every sentence here is approved fixed text; the screening result appears nowhere else. }}
80
- <rect x="0.5" y="410.5" width="862" height="263" rx="8" fill="#F8FAFC" stroke="#E2E8F0"/>
81
- <text x="843" y="452" text-anchor="start" font-size="15" font-weight="600" fill="#1E293B">تفسیر نتیجه</text>
111
+ {{! Screening cut on score 6, drawn over the fill }}
112
+ <line x1="715" y1="{{total.cut}}" x2="737" y2="{{total.cut}}" stroke="#DC2626" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="3 3"/>
82
113
 
83
- <text x="843" y="{{lines.direction}}" text-anchor="start" font-size="13.5" fill="#334155"><tspan font-weight="600">جهت نمره</tspan>: نمره بالاتر نشان‌دهنده مشکلات بیشتر در ابعاد سنجیده‌شده است.</text>
114
+ {{! Cut value, same row family as the 21 label, 2 px further from the column: right edge on x=709 }}
115
+ <text x="709" y="{{math total.cut '+' 3.06}}" text-anchor="end" direction="ltr" font-size="12" fill="#475569">6</text>
84
116
 
85
- <text x="843" y="{{lines.screen}}" text-anchor="start" font-size="13.5" fill="#334155"><tspan font-weight="600">نتیجه غربال (غربال، نه تشخیص)</tspan>: نمره کل {{total.mark}} از {{total.max}} — <tspan font-weight="600">{{screen.text}}</tspan>.</text>
117
+ {{! Red-zone label, rotated like the column title and centred between the cut and the top (21) }}
118
+ <text x="0" y="0" transform="translate(705.02, {{math (math total.cut '+' 46) '/' 2}}) rotate(-90)" text-anchor="middle" font-size="12" font-weight="500" fill="#DC2626">نیازمند بررسی بیشتر</text>
86
119
 
87
- <text x="843" y="{{lines.cutoff}}" text-anchor="start" font-size="13.5" fill="#334155"><tspan font-weight="600">برش غربال</tspan>: نمره کل 6 یا بیشتر؛ برش فقط بر نمره کل اعمال می‌شود و مولفه‌ها طبقه‌بندی نمی‌شوند.</text>
120
+ {{! Total score travelling with the tip of the bar }}
121
+ <text x="743" y="{{total.baseline}}" text-anchor="start" direction="ltr" font-size="18" font-weight="600" fill="{{ternary screen.positive '#B91C1C' '#334155'}}">{{total.mark}}</text>
88
122
 
89
- {{#if screen.positive}}
90
- <text x="843" y="{{lines.falsePositive}}" text-anchor="start" font-size="13.5" fill="#334155"><tspan font-weight="600">مثبت کاذب</tspan>: ویژگی این برش 72.2 درصد است؛ بخشی از افراد بالای برش در واقع مشکل ندارند و نمره زیر برش اطلاع‌بخش‌تر است.</text>
91
- {{/if}}
123
+ {{! Column title, bottom end on the foot of the bar }}
124
+ <text x="0" y="0" transform="translate(705.02, 317.2) rotate(-90)" text-anchor="end" font-size="12" fill="#475569">نمره کل</text>
125
+
126
+ {{! Foot of the total bar }}
127
+ <g opacity="0.5" filter="url(#psqiTotalBaseShadow)">
128
+ <path d="M716 317H736" stroke="white" stroke-width="2" stroke-linecap="round"/>
129
+ </g>
92
130
 
93
- <text x="843" y="{{lines.limits1}}" text-anchor="start" font-size="13.5" fill="#334155"><tspan font-weight="600">محدودیت</tspan>: این نتیجه بر پایه گزارش خود فرد درباره ماه گذشته است و تشخیص نیست؛ ابزار تصمیم‌یار است</text>
94
- <text x="843" y="{{lines.limits2}}" text-anchor="start" font-size="13.5" fill="#334155">و جای مصاحبه بالینی و اندازه‌گیری عینی خواب را نمی‌گیرد.</text>
131
+ <text x="711" y="49.06" text-anchor="end" direction="ltr" font-size="12" fill="#475569">{{total.max}}</text>
95
132
 
96
133
  {{else}}
97
134
 
98
135
  {{! ===================== No result (all-or-nothing) ===================== }}
99
- <rect x="181.5" y="237.5" width="500" height="199" rx="12" fill="#F8FAFC" stroke="#E2E8F0"/>
100
- <circle cx="431.5" cy="288" r="17" fill="none" stroke="#64748B" stroke-width="2"/>
101
- <line x1="431.5" y1="279" x2="431.5" y2="291" stroke="#64748B" stroke-width="2.5" stroke-linecap="round"/>
102
- <circle cx="431.5" cy="297.5" r="1.6" fill="#64748B"/>
103
- <text x="431.5" y="344" text-anchor="middle" font-size="17" font-weight="600" fill="#1E293B">نتیجه تولید نشد؛ پاسخ ناقص یا نامعتبر است</text>
104
- <text x="431.5" y="376" text-anchor="middle" font-size="13" fill="#64748B">برای این پاسخ‌نامه هیچ نمره‌ای، حتی نمره جزئی، گزارش نمی‌شود.</text>
136
+ <rect x="144.5" y="67.5" width="500" height="199" rx="12" fill="#F8FAFC" stroke="#E2E8F0"/>
137
+ <circle cx="394.5" cy="118" r="17" fill="none" stroke="#64748B" stroke-width="2"/>
138
+ <line x1="394.5" y1="109" x2="394.5" y2="121" stroke="#64748B" stroke-width="2.5" stroke-linecap="round"/>
139
+ <circle cx="394.5" cy="127.5" r="1.6" fill="#64748B"/>
140
+ <text x="394.5" y="174" text-anchor="middle" font-size="17" font-weight="600" fill="#1E293B">نتیجه تولید نشد؛ پاسخ ناقص یا نامعتبر است</text>
141
+ <text x="394.5" y="206" text-anchor="middle" font-size="13" fill="#64748B">برای این پاسخ‌نامه هیچ نمره‌ای، حتی نمره جزئی، گزارش نمی‌شود.</text>
105
142
 
106
143
  {{/if}}
107
144