Reworked the table scroll
linter / quality (push) Successful in 5m3s
tests / ci (8.4) (push) Failing after 4m29s
tests / ci (8.5) (push) Failing after 5m27s

This commit is contained in:
2026-09-09 19:34:30 +02:00
parent d9132fe6ee
commit ca6c9fe38c
7 changed files with 305 additions and 23 deletions
+4 -3
View File
@@ -40,15 +40,16 @@ const rowBgDark = computed(() => {
<template>
<table class="table w-full border-collapse border border-gray-300">
<tr class="text-left" v-if="props.iterations.length > 0">
<th>Époch</th>
<th>Exemple</th>
<th class="sticky top-0 z-10 bg-background">Époch</th>
<th class="sticky top-0 z-10 bg-background">Exemple</th>
<th
v-for="(weight, index) in displayedWeights"
v-bind:key="index"
class="sticky top-0 z-10 bg-background"
>
X<sub>{{ index }}</sub>
</th>
<th>Erreur</th>
<th class="sticky top-0 z-10 bg-background">Erreur</th>
</tr>
<tr
v-for="(iteration, index) in props.iterations"
@@ -0,0 +1,168 @@
<script setup lang="ts">
import type { ScrollAreaRootProps } from "reka-ui"
import { onBeforeUnmount, onMounted, ref, toRef, type HTMLAttributes } from "vue"
import { reactiveOmit, useScroll } from "@vueuse/core"
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaViewport,
} from "reka-ui"
import { cn } from "@/lib/utils"
import ScrollBar from "./ScrollBar.vue"
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"], keepBottom?: boolean }>()
const delegatedProps = reactiveOmit(props, "class")
const scrollAreaRootRef = ref<{ viewport: HTMLElement } | null>(null)
const { arrivedState } = useScroll(() => scrollAreaRootRef.value?.viewport)
const bottom = toRef(arrivedState, 'bottom')
const isFollowingBottom = ref(props.keepBottom ?? false)
let isAutoScrolling = false
let mutationObserver: MutationObserver | null = null
let resizeObserver: ResizeObserver | null = null
let followFrame: number | null = null
let previousScrollTop = 0
const getViewport = () => scrollAreaRootRef.value?.viewport
const isNearBottom = () => {
const viewport = getViewport()
return viewport ? viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 4 : true
}
const handleScroll = () => {
const viewport = getViewport()
if (!viewport) {
return
}
const scrollTop = viewport.scrollTop
if (scrollTop < previousScrollTop) {
isAutoScrolling = false
isFollowingBottom.value = false
}
previousScrollTop = scrollTop
if (isAutoScrolling) {
if (isNearBottom()) {
isAutoScrolling = false
}
return
}
isFollowingBottom.value = isNearBottom()
}
const handleUserScroll = () => {
isAutoScrolling = false
isFollowingBottom.value = false
const viewport = getViewport()
viewport?.scrollTo({ top: viewport.scrollTop, behavior: 'auto' })
}
const followBottomAfterUpdate = () => {
followFrame = null
if (isFollowingBottom.value) {
scrollToBottom('smooth')
}
}
const scheduleFollowBottom = () => {
if (followFrame === null) {
followFrame = requestAnimationFrame(followBottomAfterUpdate)
}
}
const isBottom = () => bottom.value
const scrollToBottom = (behavior: string = 'auto') => {
const viewport = getViewport()
if (viewport) {
isFollowingBottom.value = true
isAutoScrolling = behavior === 'smooth'
previousScrollTop = viewport.scrollTop
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: behavior === 'smooth' ? 'smooth' : 'auto',
})
}
}
const followBottom = (force = false) => {
if (!force && !isFollowingBottom.value) {
return
}
isFollowingBottom.value = true
scrollToBottom('smooth')
}
const scrollToTop = (behavior: ScrollBehavior = 'smooth') => {
const viewport = getViewport()
if (viewport) {
isAutoScrolling = false
isFollowingBottom.value = false
viewport.scrollTo({ top: 0, behavior })
}
}
onMounted(() => {
const viewport = getViewport()
if (!viewport) {
return
}
viewport.addEventListener('scroll', handleScroll, { passive: true })
viewport.addEventListener('wheel', handleUserScroll, { passive: true })
viewport.addEventListener('touchstart', handleUserScroll, { passive: true })
mutationObserver = new MutationObserver(() => {
if (props.keepBottom) {
scheduleFollowBottom()
}
})
mutationObserver.observe(viewport, { childList: true, subtree: true })
resizeObserver = new ResizeObserver(scheduleFollowBottom)
resizeObserver.observe(viewport)
if (props.keepBottom) {
scrollToBottom()
}
})
onBeforeUnmount(() => {
const viewport = getViewport()
viewport?.removeEventListener('scroll', handleScroll)
viewport?.removeEventListener('wheel', handleUserScroll)
viewport?.removeEventListener('touchstart', handleUserScroll)
mutationObserver?.disconnect()
resizeObserver?.disconnect()
if (followFrame !== null) {
cancelAnimationFrame(followFrame)
}
})
interface Expose {
isBottom: () => boolean
isFollowingBottom: typeof isFollowingBottom
followBottom: (force?: boolean) => void
scrollToBottom: (behavior?: string) => void
scrollToTop: (behavior?: ScrollBehavior) => void
}
defineExpose<Expose>({
isBottom,
isFollowingBottom,
followBottom,
scrollToBottom,
scrollToTop,
})
</script>
<template>
<ScrollAreaRoot
v-bind="delegatedProps"
:class="cn('relative overflow-hidden', props.class)"
ref="scrollAreaRootRef"
>
<ScrollAreaViewport class="h-full w-full overflow-y-auto rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<slot name="overlay" />
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { ScrollAreaScrollbarProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes["class"] }>(), {
orientation: "vertical",
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
v-bind="delegatedProps"
:class="
cn('flex touch-none p-px transition-colors select-none',
orientation === 'vertical'
&& 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal'
&& 'h-2.5 flex-col border-t border-t-transparent',
props.class)"
>
<ScrollAreaThumb
data-slot="scroll-area-thumb"
class="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaScrollbar>
</template>
@@ -0,0 +1,2 @@
export { default as ScrollArea } from "./ScrollArea.vue"
export { default as ScrollBar } from "./ScrollBar.vue"
+76 -11
View File
@@ -11,8 +11,11 @@ import {
PointElement,
LineElement,
} from 'chart.js';
import { computed, ref } from 'vue';
import { ArrowDown, ArrowUp } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import LinkHeader from '@/components/LinkHeader.vue';
import Button from '@/components/ui/button/Button.vue';
import ScrollArea from '@/components/ui/scroll-area/ScrollArea.vue';
import { usePerceptronTraining } from '@/composables/usePerceptronTraining';
import type { Dataset, DatasetPoint, InitializationMethod, PerceptronType } from '@/types/perceptron';
import IterationTable from '../components/IterationTable.vue';
@@ -105,11 +108,22 @@ function getActivationFunction(type: string): (x: number) => number {
}
}
const iterationScrollArea = ref<{
followBottom: (force?: boolean) => void;
isFollowingBottom: boolean;
scrollToTop: (behavior?: ScrollBehavior) => void;
} | null>(null);
watch([iterations, trainingEnded], async () => {
await nextTick();
iterationScrollArea.value?.followBottom();
});
</script>
<template>
<Head title="Perceptron Viewer"></Head>
<main class="space-y-6">
<main class="flex min-h-dvh flex-col gap-6 pb-6 space-y-6">
<LinkHeader class="w-full" />
<PerceptronSetup
:type="props.type"
@@ -130,18 +144,56 @@ function getActivationFunction(type: string): (x: number) => number {
@update:training-id="setTrainingId"
/>
<div
class="align-items-start justify-content-center flex h-full min-h-dvh max-w-dvw"
class="align-items-start justify-content-center grid max-w-dvw grid-cols-2"
v-if="selectedDatasetName || iterations.length > 0"
>
<div class="max-h-full w-full overflow-y-scroll">
<IterationTable
:iterations="iterations"
:trainingEnded="trainingEnded"
:trainingEndReason="trainingEndReason"
:maxDisplayedWeights="props.maxDisplayedWeights"
/>
<div class="pt-3 h-dvh max-h-dvh min-h-0 w-full min-w-0">
<ScrollArea ref="iterationScrollArea" :keep-bottom="true" type="scroll" class="relative h-full max-h-full min-h-0 w-full">
<IterationTable
:iterations="iterations"
:trainingEnded="trainingEnded"
:trainingEndReason="trainingEndReason"
:maxDisplayedWeights="props.maxDisplayedWeights"
/>
<template #overlay>
<div class="absolute right-4 bottom-4 z-10 flex flex-col items-end gap-2">
<Button
type="button"
variant="secondary"
size="icon"
class="size-9 rounded-full shadow-md"
aria-label="Revenir en haut du tableau"
title="Revenir en haut"
@click="iterationScrollArea?.scrollToTop()"
>
<ArrowUp class="size-4" />
</Button>
<Transition
enter-active-class="transition-opacity duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<Button
v-if="iterationScrollArea && !iterationScrollArea.isFollowingBottom"
type="button"
variant="secondary"
size="icon"
class="size-9 rounded-full shadow-md"
aria-label="Reprendre le défilement automatique"
title="Reprendre le défilement automatique"
@click="iterationScrollArea?.followBottom(true)"
>
<ArrowDown class="size-4" />
</Button>
</Transition>
</div>
</template>
</ScrollArea>
</div>
<div class="sticky top-0 h-full w-full">
<div class="h-full w-full min-w-0 space-y-6">
<div>
<PerceptronDecisionGraph
:cleanedDataset="cleanedDataset"
@@ -163,3 +215,16 @@ function getActivationFunction(type: string): (x: number) => number {
</div>
</main>
</template>
<style lang="css" scoped>
/* we will explain what these classes do next! */
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>