@lotics/ui 47.13.1 → 47.15.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/AGENTS.md +17 -3
- package/docs/catalog.md +228 -29
- package/docs/composition.md +83 -4
- package/docs/data_entry.md +13 -6
- package/docs/reviewing.md +71 -0
- package/docs/templates.md +1 -1
- package/examples/tpl_attendance.tsx +51 -80
- package/examples/tpl_pivot.tsx +10 -1
- package/examples/tpl_record.tsx +33 -107
- package/package.json +9 -1
- package/src/avatar_size.ts +9 -8
- package/src/axis_ticks.ts +63 -0
- package/src/bar_chart.tsx +69 -50
- package/src/calendar/calendar_view.tsx +8 -2
- package/src/cell_stack.tsx +43 -7
- package/src/chip_group.tsx +12 -6
- package/src/danger_zone.tsx +23 -10
- package/src/date_filter.tsx +17 -7
- package/src/date_filter_presets.ts +209 -1
- package/src/date_range_filter_field.tsx +14 -4
- package/src/detail_row.tsx +18 -18
- package/src/drawer.tsx +66 -28
- package/src/format_date.ts +31 -3
- package/src/gantt/gantt_view.tsx +6 -2
- package/src/index.css +18 -0
- package/src/ledger.tsx +23 -15
- package/src/line_chart.tsx +194 -61
- package/src/list_item.tsx +22 -3
- package/src/locale.tsx +25 -0
- package/src/matrix.tsx +116 -16
- package/src/matrix_totals.ts +71 -3
- package/src/month_math.ts +16 -0
- package/src/month_stepper.tsx +62 -0
- package/src/organization_name.ts +94 -0
- package/src/search_match.ts +19 -0
- package/src/section_nav.tsx +163 -0
- package/src/state_matrix.tsx +265 -0
- package/src/table.tsx +67 -5
- package/src/timetable.tsx +167 -0
- package/src/toggle_strip.tsx +259 -0
- package/src/vite.d.mts +7 -0
- package/src/vite.mjs +47 -15
package/examples/tpl_record.tsx
CHANGED
|
@@ -23,14 +23,12 @@ import { Section, SectionHeading, SectionHeadingTitle, SectionHeadingMeta, Subse
|
|
|
23
23
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
24
24
|
import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
|
|
25
25
|
import { DateStamp } from "@lotics/ui/date_stamp";
|
|
26
|
-
import {
|
|
27
|
-
import { Popover, PopoverContent } from "@lotics/ui/popover";
|
|
26
|
+
import { SectionNav } from "@lotics/ui/section_nav";
|
|
28
27
|
import { useSectionNav } from "@lotics/ui/use_section_nav";
|
|
29
28
|
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
|
|
30
29
|
import { formatMoney } from "@lotics/ui/format_money";
|
|
31
30
|
import { RecordSummary } from "@lotics/ui/record_summary";
|
|
32
31
|
import { SummaryLine } from "@lotics/ui/summary_line";
|
|
33
|
-
import { Badge } from "@lotics/ui/badge";
|
|
34
32
|
import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
|
|
35
33
|
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
36
34
|
import { Skeleton } from "@lotics/ui/skeleton";
|
|
@@ -2662,26 +2660,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2662
2660
|
// since a reference is a field); everything after it is supplementary
|
|
2663
2661
|
// (discussion, work, documents, money, lifecycle).
|
|
2664
2662
|
//
|
|
2665
|
-
//
|
|
2666
|
-
//
|
|
2667
|
-
//
|
|
2668
|
-
//
|
|
2669
|
-
// a single surface. `ReferenceField` sat here under this same note until a
|
|
2670
|
-
// register's drawer needed it, and that is what let the extraction land on the
|
|
2671
|
-
// PEEK contract instead of on the layout.
|
|
2672
|
-
//
|
|
2673
|
-
// When a second surface wants this, the contract worth lifting is: one section
|
|
2674
|
-
// list, rendered as a RAIL where the container affords a gutter and as a pinned
|
|
2675
|
-
// CURRENT-SECTION bar where it does not, the switch reading the CONTAINER (never
|
|
2676
|
-
// the window); the picker anchored to the control that opens it, never a Modal
|
|
2677
|
-
// that escapes its container; and the per-section attention dot, which is what
|
|
2678
|
-
// makes the rail answer "where is the problem" and not merely "where am I".
|
|
2679
|
-
//
|
|
2680
|
-
// What is NOT the contract, and would poison it if lifted along: the issue-tone
|
|
2681
|
-
// MAP below (its keys are this record's domain), the comments toggle (drawer
|
|
2682
|
-
// chrome), the BackButton (page chrome), the gutter arithmetic (28 page / 20
|
|
2683
|
-
// drawer, minus the trigger's padding) and the `wide` threshold. One consumer
|
|
2684
|
-
// cannot tell those apart from the contract — two can.
|
|
2663
|
+
// `SectionNav` draws both forms; `useSectionNav` spies the scroll. What stays
|
|
2664
|
+
// HERE is what reads this record's domain or this page's layout: the issue map
|
|
2665
|
+
// below (its keys are this record's), the BackButton, the gutter arithmetic
|
|
2666
|
+
// and the `wide` threshold.
|
|
2685
2667
|
const SECTIONS = [
|
|
2686
2668
|
// PROGRESS leads. The header already IDENTIFIES the record — it titles the
|
|
2687
2669
|
// customer and carries the code and city beneath it — so the first section
|
|
@@ -2694,7 +2676,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2694
2676
|
//
|
|
2695
2677
|
// The rail lists sections in PAGE order, or the scroll-spy highlights one
|
|
2696
2678
|
// entry while the reader is looking at another.
|
|
2697
|
-
{ key: "progress", label: "Progress", icon: "
|
|
2679
|
+
{ key: "progress", label: "Progress", icon: "trending-up" },
|
|
2698
2680
|
{ key: "activity", label: "Activity", icon: "message-square" },
|
|
2699
2681
|
{ key: "general", label: "General", icon: "file-text" },
|
|
2700
2682
|
// Only a SECTION when the page is too narrow to seat the panel — so the wide
|
|
@@ -2749,15 +2731,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2749
2731
|
transport: scheduleNote !== "on-time" ? "warning" : undefined,
|
|
2750
2732
|
billing: customer === null ? "warning" : undefined,
|
|
2751
2733
|
};
|
|
2752
|
-
// Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
|
|
2753
|
-
// it opens a full-page section picker (Escape / the close control dismiss).
|
|
2754
|
-
const [sectionsOpen, setSectionsOpen] = useState(false);
|
|
2755
|
-
// The picker anchors to the BAR BUTTON, so it is scoped to whatever container
|
|
2756
|
-
// that button sits in — the page, or a drawer. A Modal escaped the container
|
|
2757
|
-
// and took the whole window, which inside a drawer covered the register behind
|
|
2758
|
-
// it. `triggerRef` (not `PopoverTrigger`) because the trigger IS a Button and a
|
|
2759
|
-
// button must not nest in another pressable.
|
|
2760
|
-
const sectionsAnchor = useRef<View>(null);
|
|
2761
2734
|
const [pageWidth, setPageWidth] = useState<number | null>(null);
|
|
2762
2735
|
// THREE COLUMNS: the outline rail in the left gutter, the record, and the
|
|
2763
2736
|
// discussion panel in the right. The right gutter used to be empty — reserved
|
|
@@ -2853,63 +2826,21 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2853
2826
|
<View style={{ flex: 1, backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
|
|
2854
2827
|
{pageWidth == null || wide ? null : (
|
|
2855
2828
|
// Narrow: a pinned bar names the CURRENT section (the spy keeps it
|
|
2856
|
-
// honest while scrolling); tapping opens the
|
|
2857
|
-
//
|
|
2858
|
-
//
|
|
2859
|
-
//
|
|
2860
|
-
//
|
|
2861
|
-
//
|
|
2862
|
-
//
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
{
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
{isDrawer ?
|
|
2869
|
-
<
|
|
2870
|
-
|
|
2871
|
-
{/* MUTED, not `secondary`: this names WHERE YOU ARE, so it is a
|
|
2872
|
-
position readout that happens to be pressable, and a filled chip
|
|
2873
|
-
competes with the record's own actions for the eye. The usual cost
|
|
2874
|
-
of a fill-less button does not apply here — it is chrome in a bar
|
|
2875
|
-
with its own gutter, not a verb aligned to a column of text, so
|
|
2876
|
-
there is nothing below it for its ink to line up with. The glyph
|
|
2877
|
-
carries the affordance: `list-collapse` says WHAT the list is,
|
|
2878
|
-
where `Switcher` uses a chevron for the same shape. */}
|
|
2879
|
-
<Button
|
|
2880
|
-
title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
|
|
2881
|
-
color="muted"
|
|
2882
|
-
onPress={() => setSectionsOpen(true)}
|
|
2883
|
-
/>
|
|
2884
|
-
</View>
|
|
2885
|
-
<PopoverContent style={{ width: 248 }} disableBodyScroll>
|
|
2886
|
-
<View style={{ gap: 2 }}>
|
|
2887
|
-
{SECTIONS.map((sec) => (
|
|
2888
|
-
<MenuButton
|
|
2889
|
-
key={sec.key}
|
|
2890
|
-
icon={sec.icon}
|
|
2891
|
-
title={sec.label}
|
|
2892
|
-
current={nav.activeKey === sec.key}
|
|
2893
|
-
onPress={() => {
|
|
2894
|
-
setSectionsOpen(false);
|
|
2895
|
-
nav.jumpTo(sec.key);
|
|
2896
|
-
}}
|
|
2897
|
-
/>
|
|
2898
|
-
))}
|
|
2899
|
-
</View>
|
|
2900
|
-
</PopoverContent>
|
|
2901
|
-
</Popover>
|
|
2902
|
-
<View style={{ flex: 1 }} />
|
|
2903
|
-
{/* WHERE it sits carries the meaning: sections on the left (where you
|
|
2904
|
-
ARE), the discussion on the right (what is being SAID) — the same
|
|
2905
|
-
two poles the wide layout puts in its two gutters. */}
|
|
2906
|
-
{/* A LABEL with its count, not a bare glyph: this is one of the two
|
|
2907
|
-
things the body can show, so it has to say which — and how much is
|
|
2908
|
-
waiting, which is what decides whether you look now. In drawer chrome
|
|
2909
|
-
it toggles the body; on a narrow page it still opens the sheet, which
|
|
2910
|
-
is that layout's panel. The pair reads as a switch: whichever side the
|
|
2911
|
-
body is showing wears the filled `secondary`. */}
|
|
2912
|
-
</View>
|
|
2829
|
+
// honest while scrolling); tapping opens the section picker — no tab
|
|
2830
|
+
// strip the thumb has to scroll. The bar's gutter is the CONTENT's, and
|
|
2831
|
+
// `SectionNav` takes the trigger's own padding off it so the section
|
|
2832
|
+
// NAME starts on the same left edge as every section title below.
|
|
2833
|
+
//
|
|
2834
|
+
// Narrow keeps the back standard too, EXCEPT in a drawer, which already
|
|
2835
|
+
// has one in its own header — two would be two exits.
|
|
2836
|
+
<SectionNav
|
|
2837
|
+
items={SECTIONS.map((sec) => ({ ...sec, issue: sectionIssue[sec.key] }))}
|
|
2838
|
+
activeKey={nav.activeKey}
|
|
2839
|
+
onJump={nav.jumpTo}
|
|
2840
|
+
wide={false}
|
|
2841
|
+
barGutter={isDrawer ? DRAWER_GUTTER : 28}
|
|
2842
|
+
back={isDrawer ? undefined : <BackButton onPress={() => {}} />}
|
|
2843
|
+
/>
|
|
2913
2844
|
)}
|
|
2914
2845
|
<View style={{ flex: 1 }}>
|
|
2915
2846
|
{/* FULL-WIDTH scroller: the whole page scrolls, wherever the pointer is. */}
|
|
@@ -5239,23 +5170,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
5239
5170
|
scrolls, and out of the scroll row so the page stays wheel-scrollable
|
|
5240
5171
|
edge to edge. */}
|
|
5241
5172
|
{pageWidth == null || !wide ? null : (
|
|
5242
|
-
<View style={{ position: "absolute", top: 28, left: railLeft
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
{
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
right={sectionIssue[sec.key] ? <Badge variant="dot" color={sectionIssue[sec.key] === "danger" ? "red" : "amber"} /> : undefined}
|
|
5255
|
-
onPress={() => nav.jumpTo(sec.key)}
|
|
5256
|
-
/>
|
|
5257
|
-
))}
|
|
5258
|
-
</View>
|
|
5173
|
+
<View style={{ position: "absolute", top: 28, left: railLeft }}>
|
|
5174
|
+
<SectionNav
|
|
5175
|
+
/* Comments is a section only while the page is too narrow to seat the
|
|
5176
|
+
panel, so the wide rail drops it rather than offering a jump to
|
|
5177
|
+
something that is not there. */
|
|
5178
|
+
items={SECTIONS.filter((sec) => sec.key !== "comments").map((sec) => ({ ...sec, issue: sectionIssue[sec.key] }))}
|
|
5179
|
+
activeKey={activeSection}
|
|
5180
|
+
onJump={nav.jumpTo}
|
|
5181
|
+
wide
|
|
5182
|
+
railWidth={RAIL_W}
|
|
5183
|
+
back={<BackButton label="Records" onPress={() => {}} />}
|
|
5184
|
+
/>
|
|
5259
5185
|
</View>
|
|
5260
5186
|
)}
|
|
5261
5187
|
</View>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "47.
|
|
3
|
+
"version": "47.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./accordion": "./src/accordion.tsx",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"./data_grid": "./src/data_grid.tsx",
|
|
72
72
|
"./date_calendar": "./src/date_calendar.tsx",
|
|
73
73
|
"./date_filter": "./src/date_filter.tsx",
|
|
74
|
+
"./date_filter_presets": "./src/date_filter_presets.ts",
|
|
74
75
|
"./date_picker": "./src/date_picker.tsx",
|
|
75
76
|
"./date_range_filter_field": "./src/date_range_filter_field.tsx",
|
|
76
77
|
"./date_stamp": "./src/date_stamp.tsx",
|
|
@@ -189,6 +190,8 @@
|
|
|
189
190
|
"./markdown_types": "./src/markdown_types.ts",
|
|
190
191
|
"./remark_gfm_safe": "./src/remark_gfm_safe.ts",
|
|
191
192
|
"./matrix": "./src/matrix.tsx",
|
|
193
|
+
"./month_math": "./src/month_math.ts",
|
|
194
|
+
"./month_stepper": "./src/month_stepper.tsx",
|
|
192
195
|
"./matrix_totals": "./src/matrix_totals.ts",
|
|
193
196
|
"./media_player": {
|
|
194
197
|
"react-native": "./src/media_player.tsx",
|
|
@@ -209,6 +212,7 @@
|
|
|
209
212
|
"./option_badge": "./src/option_badge.tsx",
|
|
210
213
|
"./option_list": "./src/option_list.tsx",
|
|
211
214
|
"./option_picker": "./src/option_picker.tsx",
|
|
215
|
+
"./organization_name": "./src/organization_name.ts",
|
|
212
216
|
"./overlay_layer": "./src/overlay_layer.ts",
|
|
213
217
|
"./overlay_scope": "./src/overlay_scope.ts",
|
|
214
218
|
"./page_content": "./src/page_content.tsx",
|
|
@@ -243,6 +247,7 @@
|
|
|
243
247
|
"./search_match": "./src/search_match.ts",
|
|
244
248
|
"./section_card": "./src/section_card.tsx",
|
|
245
249
|
"./section_heading": "./src/section_heading.tsx",
|
|
250
|
+
"./section_nav": "./src/section_nav.tsx",
|
|
246
251
|
"./section_stack": "./src/section_stack.tsx",
|
|
247
252
|
"./segmented_control": "./src/segmented_control.tsx",
|
|
248
253
|
"./select": "./src/select.tsx",
|
|
@@ -267,6 +272,7 @@
|
|
|
267
272
|
"./stacked_bar_chart": "./src/stacked_bar_chart.tsx",
|
|
268
273
|
"./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
|
|
269
274
|
"./status_badge": "./src/status_badge.tsx",
|
|
275
|
+
"./state_matrix": "./src/state_matrix.tsx",
|
|
270
276
|
"./status_grid": "./src/status_grid.tsx",
|
|
271
277
|
"./step_progress": "./src/step_progress.tsx",
|
|
272
278
|
"./stepper": "./src/stepper.tsx",
|
|
@@ -295,6 +301,8 @@
|
|
|
295
301
|
"./time_options": "./src/time_options.ts",
|
|
296
302
|
"./time_picker": "./src/time_picker.tsx",
|
|
297
303
|
"./timeline": "./src/timeline.tsx",
|
|
304
|
+
"./timetable": "./src/timetable.tsx",
|
|
305
|
+
"./toggle_strip": "./src/toggle_strip.tsx",
|
|
298
306
|
"./tokens": "./src/tokens.ts",
|
|
299
307
|
"./tooltip": "./src/tooltip.tsx",
|
|
300
308
|
"./trend_chip": "./src/trend_chip.tsx",
|
package/src/avatar_size.ts
CHANGED
|
@@ -48,14 +48,15 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
|
|
|
48
48
|
* beside it. It was 48, which anchored to nothing: the bar's other controls
|
|
49
49
|
* are 40, and a 48px circle among them reads as the one thing that got out.
|
|
50
50
|
*
|
|
51
|
-
* **In a register, the rung follows what the
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* and in a table it takes those 12 from the flexible column the name is
|
|
51
|
+
* **In a register, the rung follows what the reader AIMS AT, not the
|
|
52
|
+
* density.** The row's subject takes this rung — and so does an ORGANIZATION
|
|
53
|
+
* in its own column, because a book of movements is scanned by whose they
|
|
54
|
+
* are, and at `md` such a register reads as a list of numbers that happens to
|
|
55
|
+
* carry faces. `md` is for a mark incidental to the value the reader came for
|
|
56
|
+
* — a person in an owner or assignee column, where the face is competing with
|
|
57
|
+
* the row's own mark anyway. Budget for it: this rung is 12px wider than
|
|
58
|
+
* `md`, and in a table it takes those 12 from the flexible column the name is
|
|
59
|
+
* in, so a column too tight to seat one takes `md`. */
|
|
59
60
|
lg: 40,
|
|
60
61
|
/** A profile's own header. */
|
|
61
62
|
xl: 72,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gridline VALUES a magnitude axis prints — the "nice" round numbers under
|
|
3
|
+
* the largest bar, chosen so the axis lands on about five of them.
|
|
4
|
+
*
|
|
5
|
+
* The rule belongs to the axis rather than to one chart, and it has one clause
|
|
6
|
+
* that is easy to miss: **an axis over whole things has no half.** A count of
|
|
7
|
+
* vehicles, contracts, trips or people cannot be 0,5, so a step finer than 1
|
|
8
|
+
* prints gridlines at quantities the data can never take — and it happens
|
|
9
|
+
* exactly where the numbers are smallest, because a max of 2 is where a
|
|
10
|
+
* five-tick target starts reaching below the unit. The reader is then asked to
|
|
11
|
+
* read "one and a half contracts", and the axis has stated something false
|
|
12
|
+
* about the subject rather than about the layout. So the candidate steps are
|
|
13
|
+
* filtered by what the DATA is made of: all-integer values take integer steps.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The multipliers that make a step read as round rather than arbitrary. */
|
|
17
|
+
const NICE_MULTIPLIERS = [1, 2, 2.5, 5, 10];
|
|
18
|
+
|
|
19
|
+
const TARGET_TICKS = 5;
|
|
20
|
+
|
|
21
|
+
export function axisTicks(values: number[]): number[] {
|
|
22
|
+
const maxValue = Math.max(...values, 1);
|
|
23
|
+
const integral = values.every((v) => Number.isInteger(v));
|
|
24
|
+
const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
|
|
25
|
+
|
|
26
|
+
const usable = (step: number) => step > 0 && (!integral || Number.isInteger(step));
|
|
27
|
+
|
|
28
|
+
let bestStep = magnitude;
|
|
29
|
+
let bestTickCount = Infinity;
|
|
30
|
+
|
|
31
|
+
for (const multiplier of NICE_MULTIPLIERS) {
|
|
32
|
+
const step = multiplier * magnitude;
|
|
33
|
+
if (!usable(step)) continue;
|
|
34
|
+
const tickCount = Math.ceil(maxValue / step) + 1;
|
|
35
|
+
if (Math.abs(tickCount - TARGET_TICKS) < Math.abs(bestTickCount - TARGET_TICKS)) {
|
|
36
|
+
bestStep = step;
|
|
37
|
+
bestTickCount = tickCount;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A finer pass, so a max of 168 is not left on gridlines a hundred apart. It
|
|
42
|
+
// is bounded at both ends — three to eight ticks — because this is the pass
|
|
43
|
+
// that can run away, and for whole things it is also where the halves came
|
|
44
|
+
// from.
|
|
45
|
+
for (const multiplier of NICE_MULTIPLIERS) {
|
|
46
|
+
const step = multiplier * (magnitude / 10);
|
|
47
|
+
if (!usable(step)) continue;
|
|
48
|
+
const tickCount = Math.ceil(maxValue / step) + 1;
|
|
49
|
+
if (
|
|
50
|
+
tickCount >= 3 &&
|
|
51
|
+
tickCount <= 8 &&
|
|
52
|
+
Math.abs(tickCount - TARGET_TICKS) < Math.abs(bestTickCount - TARGET_TICKS)
|
|
53
|
+
) {
|
|
54
|
+
bestStep = step;
|
|
55
|
+
bestTickCount = tickCount;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const stepsNeeded = Math.ceil(maxValue / bestStep);
|
|
60
|
+
const ticks: number[] = [];
|
|
61
|
+
for (let i = 0; i <= stepsNeeded; i++) ticks.push(i * bestStep);
|
|
62
|
+
return ticks;
|
|
63
|
+
}
|
package/src/bar_chart.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { axisLabelIndices } from "./axis_label_indices";
|
|
6
|
+
import { axisTicks } from "./axis_ticks";
|
|
6
7
|
import { useLoticsLocale } from "./locale";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_BAR_COLOR = colors.blue[500];
|
|
@@ -18,6 +19,20 @@ const AXIS_LABEL_WIDTH = 56;
|
|
|
18
19
|
* second literal there would silently stop matching the row it measures. */
|
|
19
20
|
const LABEL_GAP = 8;
|
|
20
21
|
|
|
22
|
+
/** The horizontal orientation's fixed value column. Named because the label
|
|
23
|
+
* column's cap is computed from it, and a second literal there would stop
|
|
24
|
+
* matching the row it is measuring against. */
|
|
25
|
+
const VALUE_COLUMN_WIDTH = 96;
|
|
26
|
+
|
|
27
|
+
/** What the PLOT keeps whatever the label column asked for. A bar chart states
|
|
28
|
+
* proportion; under this the bars sit on their own 2px floor and the picture
|
|
29
|
+
* claims every row is equal. */
|
|
30
|
+
const MIN_TRACK_WIDTH = 72;
|
|
31
|
+
|
|
32
|
+
/** And what the label column keeps in return — a mark and a few characters,
|
|
33
|
+
* so the rows can still be told apart. */
|
|
34
|
+
const MIN_LABEL_WIDTH = 56;
|
|
35
|
+
|
|
21
36
|
const defaultFormatNumber = (n: number): string =>
|
|
22
37
|
new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
|
|
23
38
|
|
|
@@ -60,45 +75,6 @@ export interface BarChartProps {
|
|
|
60
75
|
height?: number;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
|
-
function useAxisTicks(maxValue: number) {
|
|
64
|
-
return useMemo(() => {
|
|
65
|
-
const targetTickCount = 5;
|
|
66
|
-
const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
|
|
67
|
-
const niceMultipliers = [1, 2, 2.5, 5, 10];
|
|
68
|
-
let bestStep = magnitude;
|
|
69
|
-
let bestTickCount = Infinity;
|
|
70
|
-
|
|
71
|
-
for (const multiplier of niceMultipliers) {
|
|
72
|
-
const step = multiplier * magnitude;
|
|
73
|
-
const tickCount = Math.ceil(maxValue / step) + 1;
|
|
74
|
-
if (Math.abs(tickCount - targetTickCount) < Math.abs(bestTickCount - targetTickCount)) {
|
|
75
|
-
bestStep = step;
|
|
76
|
-
bestTickCount = tickCount;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
for (const multiplier of niceMultipliers) {
|
|
81
|
-
const step = multiplier * (magnitude / 10);
|
|
82
|
-
const tickCount = Math.ceil(maxValue / step) + 1;
|
|
83
|
-
if (
|
|
84
|
-
tickCount >= 3 &&
|
|
85
|
-
tickCount <= 8 &&
|
|
86
|
-
Math.abs(tickCount - targetTickCount) < Math.abs(bestTickCount - targetTickCount)
|
|
87
|
-
) {
|
|
88
|
-
bestStep = step;
|
|
89
|
-
bestTickCount = tickCount;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const stepsNeeded = Math.ceil(maxValue / bestStep);
|
|
94
|
-
const ticks: number[] = [];
|
|
95
|
-
for (let i = 0; i <= stepsNeeded; i++) {
|
|
96
|
-
ticks.push(i * bestStep);
|
|
97
|
-
}
|
|
98
|
-
return ticks;
|
|
99
|
-
}, [maxValue]);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
78
|
/**
|
|
103
79
|
* The canonical SVG bar chart over `data: { label, value, color?, leading? }[]` —
|
|
104
80
|
* `orientation` vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels,
|
|
@@ -119,8 +95,10 @@ export function BarChart(props: BarChartProps) {
|
|
|
119
95
|
height: barAreaHeight = 200,
|
|
120
96
|
} = props;
|
|
121
97
|
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
|
122
|
-
|
|
123
|
-
|
|
98
|
+
// The gridlines come from the VALUES, not from their maximum, because an axis
|
|
99
|
+
// over whole things must not print a half — see `axis_ticks`.
|
|
100
|
+
const ticks = useMemo(() => axisTicks(data.map((d) => d.value)), [data]);
|
|
101
|
+
const axisMax = ticks[ticks.length - 1] || maxValue;
|
|
124
102
|
|
|
125
103
|
// The vertical axis prints as many labels as the row fits and thins the rest —
|
|
126
104
|
// the same rule `LineChart` runs, because it is the axis's rule and not one
|
|
@@ -132,6 +110,45 @@ export function BarChart(props: BarChartProps) {
|
|
|
132
110
|
const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
|
|
133
111
|
setLabelsWidth(event.nativeEvent.layout.width);
|
|
134
112
|
}, []);
|
|
113
|
+
|
|
114
|
+
// THE HORIZONTAL ORIENTATION IS MEASURED TOO, AND FOR TWO REASONS AT ONCE.
|
|
115
|
+
//
|
|
116
|
+
// Its label column and its value column are FIXED, so a narrow container
|
|
117
|
+
// spends its whole width on chrome and leaves the plot a few pixels: every
|
|
118
|
+
// bar then renders at its own minimum, and a row of identical stubs is a
|
|
119
|
+
// picture stating that every entity is equal. Measured at 375, a chart with a
|
|
120
|
+
// 184px label column had a 5px track. So `labelWidth` is a REQUEST, never a
|
|
121
|
+
// reservation — it gives way before the plot does, because a trimmed name
|
|
122
|
+
// beside a readable bar still says more than a full name beside a stub.
|
|
123
|
+
//
|
|
124
|
+
// The value axis under it thins by the categorical rule: its ticks are
|
|
125
|
+
// positioned by FRACTION, so a narrow track does not crowd them, it stacks
|
|
126
|
+
// them — five money labels on a short track paint one unreadable smudge, and
|
|
127
|
+
// nothing overflows, errors or clips while it happens. The tick MARKS stay: a
|
|
128
|
+
// 1px line collides with nothing, and it is the label that could not fit.
|
|
129
|
+
const [rowWidth, setRowWidth] = useState(0);
|
|
130
|
+
const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
|
|
131
|
+
setRowWidth(event.nativeEvent.layout.width);
|
|
132
|
+
}, []);
|
|
133
|
+
const horizontal = useMemo(() => {
|
|
134
|
+
const chrome = VALUE_COLUMN_WIDTH + LABEL_GAP * 2;
|
|
135
|
+
const label =
|
|
136
|
+
rowWidth <= 0
|
|
137
|
+
? labelWidth
|
|
138
|
+
: Math.min(labelWidth, Math.max(MIN_LABEL_WIDTH, rowWidth - chrome - MIN_TRACK_WIDTH));
|
|
139
|
+
const track = rowWidth <= 0 ? 0 : rowWidth - label - chrome;
|
|
140
|
+
return {
|
|
141
|
+
labelWidth: label,
|
|
142
|
+
// Evenly-spaced ticks from 0 to the axis maximum sit at `i / (count - 1)`
|
|
143
|
+
// — the POINT geometry `axisLabelIndices` already serves for a line's x
|
|
144
|
+
// axis, anchored on the last tick because that is the one setting scale.
|
|
145
|
+
// Keyed on the MEASUREMENT, not on the track: unmeasured shows every
|
|
146
|
+
// label (thinning is what measurement buys), while a measured track of
|
|
147
|
+
// zero is a real answer and `axisLabelIndices` returns the one label that
|
|
148
|
+
// states the scale.
|
|
149
|
+
keptTicks: rowWidth <= 0 ? null : new Set(axisLabelIndices(ticks.length, Math.max(track, 0))),
|
|
150
|
+
};
|
|
151
|
+
}, [rowWidth, labelWidth, ticks.length]);
|
|
135
152
|
// ONE derivation from the ONE measurement — which labels survive AND how wide
|
|
136
153
|
// the box each survivor gets. They are two answers about the same row, and a
|
|
137
154
|
// second pass over the same numbers is exactly how they came to disagree about
|
|
@@ -193,14 +210,14 @@ export function BarChart(props: BarChartProps) {
|
|
|
193
210
|
if (orientation === "horizontal") {
|
|
194
211
|
return (
|
|
195
212
|
<View style={styles.chartContainer}>
|
|
196
|
-
<View style={styles.horizontalChart}>
|
|
213
|
+
<View style={styles.horizontalChart} onLayout={handleRowLayout}>
|
|
197
214
|
{data.map((item, index) => {
|
|
198
215
|
const widthPercent = (item.value / axisMax) * 100;
|
|
199
216
|
const barColor = item.color ?? DEFAULT_BAR_COLOR;
|
|
200
217
|
|
|
201
218
|
return (
|
|
202
219
|
<View key={index} style={styles.horizontalBarContainer}>
|
|
203
|
-
<View style={[styles.horizontalBarLabel, { width: labelWidth }]}>
|
|
220
|
+
<View style={[styles.horizontalBarLabel, { width: horizontal.labelWidth }]}>
|
|
204
221
|
{item.leading !== undefined ? item.leading : null}
|
|
205
222
|
<Text size="sm" numberOfLines={1}>
|
|
206
223
|
{item.label}
|
|
@@ -226,17 +243,19 @@ export function BarChart(props: BarChartProps) {
|
|
|
226
243
|
);
|
|
227
244
|
})}
|
|
228
245
|
<View style={styles.horizontalAxisContainer}>
|
|
229
|
-
<View style={{ width: labelWidth }} />
|
|
246
|
+
<View style={{ width: horizontal.labelWidth }} />
|
|
230
247
|
<View style={styles.horizontalAxisTrack}>
|
|
231
|
-
{
|
|
248
|
+
{ticks.map((tick, index) => (
|
|
232
249
|
<View
|
|
233
250
|
key={index}
|
|
234
251
|
style={[styles.horizontalAxisTick, { left: `${(tick / axisMax) * 100}%` }]}
|
|
235
252
|
>
|
|
236
253
|
<View style={styles.tickMark} />
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
254
|
+
{horizontal.keptTicks === null || horizontal.keptTicks.has(index) ? (
|
|
255
|
+
<Text size="xs" color="muted">
|
|
256
|
+
{formatNumber(tick)}
|
|
257
|
+
</Text>
|
|
258
|
+
) : null}
|
|
240
259
|
</View>
|
|
241
260
|
))}
|
|
242
261
|
</View>
|
|
@@ -252,7 +271,7 @@ export function BarChart(props: BarChartProps) {
|
|
|
252
271
|
<View style={styles.verticalChartWrapper}>
|
|
253
272
|
<View style={styles.verticalChartRow}>
|
|
254
273
|
<View style={[styles.verticalAxis, { height: barAreaHeight }]}>
|
|
255
|
-
{
|
|
274
|
+
{ticks
|
|
256
275
|
.slice()
|
|
257
276
|
.reverse()
|
|
258
277
|
.map((tick, index) => (
|
|
@@ -411,7 +430,7 @@ const styles = StyleSheet.create({
|
|
|
411
430
|
flex: 1,
|
|
412
431
|
},
|
|
413
432
|
horizontalBarValue: {
|
|
414
|
-
width:
|
|
433
|
+
width: VALUE_COLUMN_WIDTH,
|
|
415
434
|
alignItems: "flex-end",
|
|
416
435
|
},
|
|
417
436
|
horizontalBar: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo, useState, type ReactNode } from "react";
|
|
2
2
|
import { View, StyleSheet, type LayoutChangeEvent } from "react-native";
|
|
3
3
|
import { colors } from "../colors";
|
|
4
|
-
import { useLoticsLocale } from "../locale";
|
|
4
|
+
import { useLoticsLocale, useLocaleTag } from "../locale";
|
|
5
5
|
import { CalendarProvider, useCalendar, type CalendarContextValue } from "./context";
|
|
6
6
|
import { CalendarToolbar, CALENDAR_TOOLBAR_HEIGHT } from "./calendar_toolbar";
|
|
7
7
|
import { CalendarMonth, MIN_MONTH_HEIGHT } from "./month_view";
|
|
@@ -33,6 +33,8 @@ export interface CalendarViewProps<T = unknown> {
|
|
|
33
33
|
/** Which modes the built-in toolbar offers. Default: all four. */
|
|
34
34
|
views?: CalendarViewMode[];
|
|
35
35
|
weekStartsOn?: Weekday;
|
|
36
|
+
/** BCP-47 tag for the weekday / month / time names. Defaults to the active
|
|
37
|
+
* `LoticsLocaleProvider` pack's `bcp47`; pass one only to override it. */
|
|
36
38
|
locale?: string;
|
|
37
39
|
/** Per-instance overrides; the rest resolve from `LoticsLocale.calendarView`. */
|
|
38
40
|
labels?: Partial<CalendarViewLabels>;
|
|
@@ -88,13 +90,17 @@ export interface CalendarViewProps<T = unknown> {
|
|
|
88
90
|
*/
|
|
89
91
|
export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
|
|
90
92
|
const {
|
|
91
|
-
events, weekStartsOn = 1,
|
|
93
|
+
events, weekStartsOn = 1, views = ALL_VIEWS,
|
|
92
94
|
dayStartHour = 0, dayEndHour = 24, compactBelow = DEFAULT_COMPACT_BELOW, now,
|
|
93
95
|
onEventPress, onSlotPress, renderEvent, children,
|
|
94
96
|
} = props;
|
|
95
97
|
|
|
96
98
|
const pack = useLoticsLocale();
|
|
97
99
|
const labels: CalendarViewLabels = { ...pack.calendarView, ...props.labels };
|
|
100
|
+
// Every Intl rendering the grid does (weekday and month names, the times down
|
|
101
|
+
// the day axis) reads this, so the active pack alone localizes the calendar —
|
|
102
|
+
// an app that already supplies `vi` must not also thread a `locale` prop.
|
|
103
|
+
const locale = useLocaleTag(props.locale);
|
|
98
104
|
|
|
99
105
|
// One reading of the clock for the whole tree, so the badge, the now line and
|
|
100
106
|
// the scroll target cannot disagree across a midnight boundary mid-render.
|