Просмотр исходного кода

Improve top models chart mobile axis

Adam 3 месяцев назад
Родитель
Сommit
b623d86f10
2 измененных файлов с 84 добавлено и 13 удалено
  1. 34 5
      packages/stats/app/src/routes/index.css
  2. 50 8
      packages/stats/app/src/routes/index.tsx

+ 34 - 5
packages/stats/app/src/routes/index.css

@@ -630,6 +630,7 @@
 }
 
 [data-page="stats"] [data-component="top-models-chart"] {
+  --top-models-bar-gap: 12px;
   position: relative;
   display: grid;
   grid-template-rows: 34px minmax(0, 1fr);
@@ -641,10 +642,13 @@
 [data-page="stats"] [data-slot="top-models-axis"],
 [data-page="stats"] [data-slot="top-models-bars"] {
   display: flex;
-  gap: 12px;
   min-width: 0;
 }
 
+[data-page="stats"] [data-slot="top-models-axis"] {
+  gap: var(--top-models-bar-gap);
+}
+
 [data-page="stats"] [data-slot="top-models-axis"] > div {
   flex: 1 1 0;
   min-width: 0;
@@ -664,43 +668,54 @@
   flex-direction: column;
 }
 
+[data-page="stats"] [data-slot="axis-date-mobile"] {
+  display: none;
+}
+
 [data-page="stats"] [data-slot="top-models-bars"] {
+  width: calc(100% + var(--top-models-bar-gap));
+  margin-inline: calc(var(--top-models-bar-gap) / -2);
   min-height: 0;
 }
 
 [data-page="stats"] [data-slot="top-models-bar"] {
   position: relative;
+  box-sizing: border-box;
   flex: 1 1 0;
   min-width: 0;
   height: 100%;
+  padding-inline: calc(var(--top-models-bar-gap) / 2);
   outline: none;
   cursor: pointer;
 }
 
 [data-page="stats"] [data-slot="top-models-bar"]::before {
   position: absolute;
-  inset: 0;
+  inset: 0 calc(var(--top-models-bar-gap) / 2);
   content: "";
   background: var(--stats-dot);
   mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
+  mask-position: center top;
   mask-repeat: repeat;
   mask-size: 6px 6px;
   -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
+  -webkit-mask-position: center top;
   -webkit-mask-repeat: repeat;
   -webkit-mask-size: 6px 6px;
 }
 
 [data-page="stats"] [data-slot="top-models-stack"] {
   position: absolute;
-  right: 0;
+  right: calc(var(--top-models-bar-gap) / 2);
   bottom: 0;
-  left: 0;
+  left: calc(var(--top-models-bar-gap) / 2);
   z-index: 1;
   display: grid;
   height: var(--top-models-bar-height);
   min-height: 0;
   overflow: hidden;
   background: var(--stats-bg);
+  box-shadow: 0 -4px 0 var(--stats-bg);
 }
 
 [data-page="stats"] [data-slot="top-models-stack"] i {
@@ -1118,6 +1133,10 @@
   line-height: 12px;
 }
 
+[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p[data-muted="true"] {
+  opacity: 0.46;
+}
+
 [data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] + p {
   margin-top: 8px;
 }
@@ -2026,11 +2045,21 @@
     position: absolute;
     left: 50%;
     width: max-content;
-    max-width: 96px;
+    max-width: 72px;
     transform: rotate(-90deg) translateX(-50%);
     transform-origin: left center;
   }
 
+  [data-page="stats"] [data-slot="top-models-axis"] > div[data-mobile-hidden="true"] [data-slot="axis-label"],
+  [data-page="stats"] [data-slot="axis-total"],
+  [data-page="stats"] [data-slot="axis-date-full"] {
+    display: none;
+  }
+
+  [data-page="stats"] [data-slot="axis-date-mobile"] {
+    display: block;
+  }
+
   [data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] {
     display: none;
   }

+ 50 - 8
packages/stats/app/src/routes/index.tsx

@@ -403,6 +403,7 @@ function FilterPills<T extends string>(props: {
 
 function TopModelsChart(props: { data: UsagePoint[]; range: UsageRange }) {
   const [activeIndex, setActiveIndex] = createSignal<number>()
+  const [activeSegment, setActiveSegment] = createSignal<number>()
   const maxTotal = createMemo(() => getTopModelsMaxTotal(props.data))
   const activePoint = createMemo(() => props.data[activeIndex() ?? -1])
 
@@ -411,10 +412,16 @@ function TopModelsChart(props: { data: UsagePoint[]; range: UsageRange }) {
       <div data-slot="top-models-axis" aria-hidden="true">
         <For each={props.data}>
           {(day, index) => (
-            <div data-active={activeIndex() === index() ? "true" : undefined}>
+            <div
+              data-active={activeIndex() === index() ? "true" : undefined}
+              data-mobile-hidden={isTopModelsMobileAxisHidden(index(), props.data.length) ? "true" : undefined}
+            >
               <span data-slot="axis-label">
-                <span>{formatTokens(usageTotal(day))}</span>
-                <span>{day.date}</span>
+                <span data-slot="axis-total">{formatTokens(usageTotal(day))}</span>
+                <span data-slot="axis-date">
+                  <span data-slot="axis-date-full">{day.date}</span>
+                  <span data-slot="axis-date-mobile">{formatTopModelsMobileDate(day.date, props.range)}</span>
+                </span>
               </span>
             </div>
           )}
@@ -431,30 +438,49 @@ function TopModelsChart(props: { data: UsagePoint[]; range: UsageRange }) {
               data-active={activeIndex() === dayIndex() ? "true" : undefined}
               data-muted={activeIndex() !== undefined && activeIndex() !== dayIndex() ? "true" : undefined}
               style={{ "--top-models-bar-height": `${getTopModelsBarHeight(usageTotal(day), maxTotal())}%` }}
-              onPointerEnter={() => setActiveIndex(dayIndex())}
+              onPointerEnter={() => {
+                setActiveIndex(dayIndex())
+                setActiveSegment(undefined)
+              }}
               onPointerLeave={(event) => {
                 if (event.pointerType === "touch") return
                 setActiveIndex(undefined)
+                setActiveSegment(undefined)
               }}
               onClick={() => setActiveIndex(dayIndex())}
-              onFocus={() => setActiveIndex(dayIndex())}
-              onBlur={() => setActiveIndex(undefined)}
+              onFocus={() => {
+                setActiveIndex(dayIndex())
+                setActiveSegment(undefined)
+              }}
+              onBlur={() => {
+                setActiveIndex(undefined)
+                setActiveSegment(undefined)
+              }}
               onKeyDown={(event) => {
                 if (event.key !== "Enter" && event.key !== " ") return
                 event.preventDefault()
                 setActiveIndex(dayIndex())
+                setActiveSegment(undefined)
               }}
             >
               <div data-slot="top-models-stack" style={{ "grid-template-rows": getTopModelsSegmentRows(day) }}>
                 <For each={visibleTopModelsSegments(day)}>
                   {(item) => (
                     <i
+                      data-series={item.index}
+                      data-active={activeSegment() === item.index ? "true" : undefined}
                       style={{
                         background: getTopModelsSegmentColor(
                           item.index,
                           activeIndex() !== undefined && activeIndex() !== dayIndex(),
+                          activeSegment(),
                         ),
                       }}
+                      onPointerEnter={(event) => {
+                        event.stopPropagation()
+                        setActiveIndex(dayIndex())
+                        setActiveSegment(item.index)
+                      }}
                     />
                   )}
                 </For>
@@ -467,7 +493,12 @@ function TopModelsChart(props: { data: UsagePoint[]; range: UsageRange }) {
                     <div data-slot="tooltip-divider" />
                     <For each={visibleTopModelsSegments(point())}>
                       {(item) => (
-                        <p>
+                        <p
+                          data-active={activeSegment() === item.index ? "true" : undefined}
+                          data-muted={
+                            activeSegment() !== undefined && activeSegment() !== item.index ? "true" : undefined
+                          }
+                        >
                           <span data-slot="tooltip-label">
                             <i style={{ background: usageColors[item.index] }} /> {item.segment.model}
                           </span>
@@ -510,11 +541,22 @@ function visibleTopModelsSegments(point: UsagePoint) {
   return point.segments.map((segment, index) => ({ segment, index })).filter((item) => item.segment.value > 0)
 }
 
-function getTopModelsSegmentColor(index: number, muted: boolean) {
+function getTopModelsSegmentColor(index: number, muted: boolean, activeSegment: number | undefined) {
+  if (activeSegment !== undefined)
+    return activeSegment === index ? (usageColors[index] ?? "var(--stats-text)") : "var(--stats-layer-2)"
   if (muted) return "var(--stats-layer-2)"
   return usageColors[index] ?? "var(--stats-text)"
 }
 
+function isTopModelsMobileAxisHidden(index: number, count: number) {
+  return count > 7 && index % 2 === 1
+}
+
+function formatTopModelsMobileDate(label: string, range: UsageRange) {
+  if (range === "1M" || range === "2M") return label.split(" - ")[0] ?? label
+  return label
+}
+
 function usageTotal(point: UsagePoint) {
   return point.segments.reduce((sum, item) => sum + item.value, 0)
 }