Skip to content

Commit e7936d2

Browse files
authored
improved sticky header to show resource layer (#663)
1 parent 4c94353 commit e7936d2

5 files changed

Lines changed: 133 additions & 73 deletions

File tree

web/src/app/timeline/components/calculator/vertical-scroll-calculator.spec.ts

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -219,61 +219,67 @@ describe('VerticalScrollCalculator', () => {
219219
TimelineLayer.Name, // 300-400 (Pod2)
220220
TimelineLayer.Namespace, // 400-500 (Namespace2)
221221
TimelineLayer.Name, // 500-600 (Pod3)
222-
TimelineLayer.Kind, // 600-700 (Kind2)
223-
TimelineLayer.Namespace, // 700-800 (Namespace3)
222+
TimelineLayer.Subresource, // 600-650 (Subresource1)
223+
TimelineLayer.Kind, // 650-750 (Kind2)
224+
TimelineLayer.Namespace, // 750-850 (Namespace3)
225+
TimelineLayer.Name, // 850-950 (Pod4)
226+
TimelineLayer.Subresource, // 950-1050 (Subresource2)
224227
]);
225228
calculator = new VerticalScrollCalculator(timelines, mockStyle, 0);
226229
});
227230

228231
it('should return initial sticky header at scroll 0', () => {
229232
const result = calculator.stickyTimelines(0);
230-
expect(result.length).toBe(2);
233+
expect(result.length).toBe(3);
231234
expect(result[0]).toBe(timelines[0]);
232235
expect(result[1]).toBe(timelines[1]);
236+
expect(result[2]).toBe(timelines[2]);
233237
});
234238

235-
it('should maintain current sticky header before next header arrives (scroll 199)', () => {
236-
// Namespace2 starts at 400.
237-
// 400 - 199 = 201.
238-
// Sticky header area is 200.
239-
// So Namespace2 is NOT yet sticky.
240-
const result = calculator.stickyTimelines(199);
239+
it('should maintain current sticky header before next header arrives (scroll 99)', () => {
240+
// 99 + 200 = 299 -> invades Pod1 (200-300).
241+
const result = calculator.stickyTimelines(99);
242+
expect(result.length).toBe(2);
241243
expect(result[0]).toBe(timelines[0]);
242244
expect(result[1]).toBe(timelines[1]);
245+
// The next item is also the Name layer, so the sticky header is up to the namespace layer.
243246
});
244247

245-
it('should maintain current sticky header at exact boundary (scroll 200)', () => {
246-
// Namespace2 is at 200 from viewport top (400 - 200 = 200).
247-
// Sticky header area is 200.
248-
const result = calculator.stickyTimelines(200);
248+
it('should maintain current sticky header at exact boundary (scroll 100)', () => {
249+
// 100 + 200 = 300 -> reaches Pod2 (300-400).
250+
const result = calculator.stickyTimelines(100);
251+
expect(result.length).toBe(2);
249252
expect(result[0]).toBe(timelines[0]); // Kind1
250253
expect(result[1]).toBe(timelines[1]); // Namespace1
251254
});
252255

253-
it('should switch to next sticky header after boundary (scroll 201)', () => {
254-
// Namespace2 is at 199 relative to viewport top (invading sticky area).
255-
// Should pick Namespace2.
256-
const result = calculator.stickyTimelines(201);
256+
it('should switch to next sticky header after boundary when the next item is name resource (scroll 101)', () => {
257+
// 101 + 200 = 301 -> inside Pod2.
258+
const result = calculator.stickyTimelines(101);
259+
expect(result.length).toBe(2);
257260
expect(result[0]).toBe(timelines[0]); // Kind1
258-
expect(result[1]).toBe(timelines[4]); // Namespace2
261+
expect(result[1]).toBe(timelines[1]); // Namespace1
262+
// The next item is also the Name layer, so the sticky header is up to the namespace layer.
259263
});
260264

261-
it('should update both Kind and Namespace when scrolling deep into next section (scroll 550)', () => {
262-
// Scroll 550 (inside Pod3, Namespace2, Kind1)
263-
// Kind2 starts at 600.
264-
// Scroll 550 + 200 = 750.
265-
// Returns [Kind2, Namespace3].
265+
it('should return sticky timelines only when the sticky timeline is fully visible (scroll 550)', () => {
266+
// Scroll 550 (inside Pod3, Namespace2, Kind1).
267+
// Kind2 starts at 600. There is no enough space to show Kind1 & Namespace1 as stickyTimelines
268+
// Scroll 550 + 300 = 850.
269+
// Returns [Kind1].
266270
const result = calculator.stickyTimelines(550);
267-
expect(result[0]).toBe(timelines[6]); // Kind2
268-
expect(result[1]).toBe(timelines[7]); // Namespace3
271+
expect(result.length).toBe(1);
272+
expect(result[0]).toBe(timelines[0]); // Kind1
269273
});
270274

271275
it('should maintain the last sticky header when scrolling past total height', () => {
272-
// Total height is 800.
273-
// Scroll 1000.
274-
const result = calculator.stickyTimelines(1000);
275-
expect(result[0]).toBe(timelines[6]); // Kind2
276-
expect(result[1]).toBe(timelines[7]); // Namespace3
276+
// Total height is 1050.
277+
// Scroll 1200.
278+
const result = calculator.stickyTimelines(1200);
279+
expect(result.length).toBe(3);
280+
expect(result[0]).toBe(timelines[7]); // Kind2
281+
expect(result[1]).toBe(timelines[8]); // Namespace3
282+
expect(result[2]).toBe(timelines[9]); // Pod4
277283
});
278284
});
279285
});

web/src/app/timeline/components/calculator/vertical-scroll-calculator.ts

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export class VerticalScrollCalculator {
133133
}
134134

135135
/**
136-
* Returns the sticky timelines (e.g. Kind, Namespace) that should be pinned to the top of the view
136+
* Returns the sticky timelines (e.g. Kind, Namespace, Name) that should be pinned to the top of the view
137137
* at the current scroll position.
138138
*
139139
* @param scrollY Current vertical scroll position (px)
@@ -143,33 +143,54 @@ export class VerticalScrollCalculator {
143143
if (this.accumulatedHeights.length === 0) {
144144
return [];
145145
}
146-
const stickyHeaderSize =
147-
this.style.heightsByLayer[TimelineLayer.Kind] +
148-
this.style.heightsByLayer[TimelineLayer.Namespace];
149-
let i = bisectLeft(
150-
this.accumulatedHeights,
151-
scrollY + stickyHeaderSize,
152-
defaultNumberComparator,
153-
); // Starting from the timeline that is at least visible behind the sticky header
154-
i = Math.min(Math.max(0, i - 1), this.timelines.length - 1);
155-
let namespaceTimeline: ResourceTimeline | null = null;
156-
for (; i >= 0; i--) {
157-
if (this.timelines[i].layer === TimelineLayer.Namespace) {
158-
namespaceTimeline = this.timelines[i];
159-
break;
146+
let maxStickyLayer = TimelineLayer.Name;
147+
let currIndex = 0;
148+
// At scroll position 0, simply returns the timelines up to the maximum sticky layer to ensure initial shadow consistency.
149+
// While an empty array visually behaves almost the same, having actual items prevents the bottom shadow from disappearing initially.
150+
if (scrollY === 0) {
151+
const result = [];
152+
for (let i = 0; i < this.timelines.length; i++) {
153+
result.push(this.timelines[i]);
154+
if (this.timelines[i].layer === maxStickyLayer) {
155+
break;
156+
}
160157
}
158+
return result;
161159
}
162-
let kindTimeline: ResourceTimeline | null = null;
163-
for (; i >= 0; i--) {
164-
if (this.timelines[i].layer === TimelineLayer.Kind) {
165-
kindTimeline = this.timelines[i];
160+
// Looks ahead by the total size of the candidate sticky header. If the resulting timeline layer matches the candidate layer, it would overlap (e.g. a Pod row sticking over another Pod row).
161+
// In such cases, shrinks the maximum sticky layer candidate by one to fallback to an upper layer timeline as the base context.
162+
for (; maxStickyLayer > TimelineLayer.APIVersion; maxStickyLayer--) {
163+
let stickyHeaderSize = 0;
164+
for (let l = TimelineLayer.APIVersion; l <= maxStickyLayer; l++) {
165+
stickyHeaderSize += this.style.heightsByLayer[l];
166+
}
167+
let i = bisectLeft(
168+
this.accumulatedHeights,
169+
scrollY + stickyHeaderSize,
170+
defaultNumberComparator,
171+
);
172+
i = Math.min(Math.max(0, i - 1), this.timelines.length - 1);
173+
if (this.timelines[i].layer > maxStickyLayer) {
174+
currIndex = i;
166175
break;
167176
}
168177
}
169-
if (kindTimeline === null || namespaceTimeline === null) {
170-
return [];
178+
179+
// Retrieves the ancestor timelines for each target sticky layer from the established base index.
180+
const result: ResourceTimeline[] = [];
181+
for (let l = maxStickyLayer; l >= TimelineLayer.Kind; l--) {
182+
for (let j = currIndex; j >= 0; j--) {
183+
if (this.timelines[j].layer === l) {
184+
result.push(this.timelines[j]);
185+
currIndex = j;
186+
break;
187+
}
188+
if (this.timelines[j].layer < l) {
189+
break;
190+
}
191+
}
171192
}
172-
return [kindTimeline, namespaceTimeline];
193+
return result.filter((timeline) => timeline !== null).reverse();
173194
}
174195

175196
/**

web/src/app/timeline/components/timeline-frame.component.html

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,25 @@
5151
(scrollOnRuler)="onWheelForScaling($event)"
5252
></khi-timeline-ruler>
5353
</div>
54-
<div class="chart-sticky-header">
55-
@if (stickyChartViewModel().timelinesInDrawArea.length > 0) {
56-
<div class="kind-placeholder"></div>
57-
<div class="namespace-placeholder"></div>
58-
}
54+
<div class="chart-sticky-header" [style.height.px]="stickyHeaderHeight()">
55+
<khi-timeline-chart
56+
[style.height.px]="maxStickyHeaderHeight()"
57+
[chartViewModel]="stickyChartViewModel()"
58+
[rulerViewModel]="rulerViewModel()"
59+
[pixelsPerMs]="pixelsPerMs()"
60+
[leftEdgeTime]="contentLeftTime()"
61+
[chartStyle]="chartStyle()"
62+
[rulerStyle]="rulerStyle()"
63+
[timelineHighlights]="timelineHighlights()"
64+
[timelineChartItemHighlights]="timelineChartItemHighlights()"
65+
[activeLogsIndices]="activeLogsIndices()"
66+
(mouseMoveOnTimelineItem)="
67+
handleTimelineChartItemEvent($event, hoverOnTimelineItem)
68+
"
69+
(clickOnTimelineItem)="
70+
handleTimelineChartItemEvent($event, clickOnTimelineItem)
71+
"
72+
></khi-timeline-chart>
5973
</div>
6074
<!--Elements stick to left edge-->
6175
<div class="index">

web/src/app/timeline/components/timeline-frame.component.scss

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ $z-index-hover-overlay: 1500;
2828
$z-index-sticky-chart: 501;
2929
$z-index-chart: 6;
3030
$z-index-resizer: 1000;
31+
$sticky-header-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.6);
3132

3233
// stick-to-header mixin is for the container element sticky to the header area.
3334
// margin-left is applied to align with the right grid area (header or chart) without considering the size of the gutter.
@@ -181,24 +182,15 @@ $z-index-resizer: 1000;
181182
}
182183

183184
.chart-sticky-header {
184-
height: 50px;
185185
@include stick-to-header(60px);
186186
z-index: $z-index-sticky-chart;
187+
box-shadow: $sticky-header-shadow;
188+
overflow: hidden;
187189

188-
.kind-placeholder {
189-
height: 25px;
190-
background-color: rgba(63, 81, 181, 0.9);
191-
position: relative;
192-
z-index: 2;
193-
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.3);
194-
}
195-
196-
.namespace-placeholder {
197-
height: 25px;
198-
background-color: rgba(100, 100, 100, 0.9);
199-
position: relative;
200-
z-index: 1;
201-
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.3);
190+
khi-timeline-chart {
191+
width: var(--content-render-width);
192+
transform: translateX(var(--content-horizontal-offset));
193+
will-change: transform;
202194
}
203195
}
204196

@@ -332,6 +324,8 @@ $z-index-resizer: 1000;
332324

333325
.sticky-index {
334326
grid-area: index;
327+
align-self: start;
328+
box-shadow: $sticky-header-shadow;
335329

336330
.sticky-index-inner {
337331
pointer-events: all;

web/src/app/timeline/components/timeline-frame.component.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
} from './timeline-chart.component';
4444
import { CaptureShiftKeyDirective } from 'src/app/common/capture-shiftkey.directive';
4545
import { TimelineCornerIndicatorComponent } from './timeline-corner-indicator.component';
46-
import { ResourceTimeline } from 'src/app/store/timeline';
46+
import { ResourceTimeline, TimelineLayer } from 'src/app/store/timeline';
4747
import { MatIconModule } from '@angular/material/icon';
4848
import { KHIIconRegistrationModule } from 'src/app/shared/module/icon-registration.module';
4949
import { CommonModule } from '@angular/common';
@@ -536,6 +536,31 @@ export class TimelineFrameComponent implements AfterViewInit {
536536
);
537537
});
538538

539+
/**
540+
* The total height of the sticky timelines in pixels.
541+
*/
542+
protected readonly stickyHeaderHeight = computed(() => {
543+
const stickyTimelines = this.stickyTimelines();
544+
const style = this.chartStyle();
545+
let height = 0;
546+
for (const t of stickyTimelines) {
547+
height += style.heightsByLayer[t.layer] ?? 0;
548+
}
549+
return height;
550+
});
551+
552+
/**
553+
* The maximum possible height of the sticky header in pixels.
554+
*/
555+
protected readonly maxStickyHeaderHeight = computed(() => {
556+
const style = this.chartStyle();
557+
let height = 0;
558+
for (let l = TimelineLayer.Kind; l <= TimelineLayer.Name; l++) {
559+
height += style.heightsByLayer[l] ?? 0;
560+
}
561+
return height;
562+
});
563+
539564
/**
540565
* The total height of all timelines content in pixels.
541566
*/

0 commit comments

Comments
 (0)