diff --git a/assets/js/filters.js b/assets/js/filters.js new file mode 100644 index 0000000..9d6fde0 --- /dev/null +++ b/assets/js/filters.js @@ -0,0 +1,150 @@ +// Filters widget — categories/tags filter chips for site listings. +// Extracted from layouts/partials/filters.html. +(function () { + 'use strict'; + + function init() { + const categoryFilter = document.getElementById('category-filter'); + const tagFilter = document.getElementById('tag-filter'); + const activeFilters = document.getElementById('active-filters'); + const clearFiltersBtn = document.getElementById('clear-filters'); + const posts = document.querySelectorAll('.post-card'); + const filteredCount = document.getElementById('filtered-count'); + const filteredPosts = document.getElementById('filtered-posts'); + const initialMessage = document.getElementById('initial-message'); + const filterStats = document.getElementById('filter-stats'); + + if (!categoryFilter || !tagFilter || !activeFilters || !clearFiltersBtn) return; + + const graphColors = [ + 'var(--graph-category-1)', 'var(--graph-category-2)', + 'var(--graph-category-3)', 'var(--graph-category-4)', + 'var(--graph-category-5)', 'var(--graph-category-6)', + 'var(--graph-category-7)', 'var(--graph-category-8)' + ]; + + const activeFilterMap = new Map(); + const chipColorMap = new Map(); + + const getRandomColor = () => + graphColors[Math.floor(Math.random() * graphColors.length)]; + + function updateFilterStats() { + const visiblePosts = document.querySelectorAll('.post-card[style="display: block;"]'); + if (filteredCount) filteredCount.textContent = String(visiblePosts.length); + } + + function createFilterChip(type, value) { + if (!chipColorMap.has(value)) chipColorMap.set(value, getRandomColor()); + const chip = document.createElement('span'); + chip.classList.add('filter-chip'); + chip.style.backgroundColor = chipColorMap.get(value); + chip.textContent = value; + chip.dataset.type = type; + chip.dataset.value = value; + + const closeBtn = document.createElement('button'); + closeBtn.classList.add('chip-close'); + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', () => removeFilter(type, value)); + chip.appendChild(closeBtn); + return chip; + } + + function updateSelectOptions() { + [ + { select: categoryFilter, type: 'category' }, + { select: tagFilter, type: 'tag' } + ].forEach(({ select, type }) => { + const filters = activeFilterMap.get(type) || new Set(); + Array.from(select.options).forEach(option => { + if (option.value && filters.has(option.value)) { + option.disabled = true; + option.style.display = 'none'; + } else { + option.disabled = false; + option.style.display = ''; + } + }); + }); + } + + function addFilter(type, value) { + if (!value) return; + if (!activeFilterMap.has(type)) activeFilterMap.set(type, new Set()); + activeFilterMap.get(type).add(value); + + activeFilters.appendChild(createFilterChip(type, value)); + updateVisibility(); + updateSelectOptions(); + + if (type === 'category') categoryFilter.value = ''; + if (type === 'tag') tagFilter.value = ''; + } + + function removeFilter(type, value) { + const filters = activeFilterMap.get(type); + if (filters) { + filters.delete(value); + if (filters.size === 0) activeFilterMap.delete(type); + } + + const chip = activeFilters.querySelector(`[data-type="${type}"][data-value="${value}"]`); + if (chip) chip.remove(); + + updateVisibility(); + updateSelectOptions(); + } + + function updateVisibility() { + const hasActiveFilters = activeFilterMap.size > 0; + if (initialMessage) initialMessage.style.display = hasActiveFilters ? 'none' : 'flex'; + if (filteredPosts) filteredPosts.style.display = hasActiveFilters ? 'block' : 'none'; + if (filterStats) filterStats.classList.toggle('hidden', !hasActiveFilters); + + if (!hasActiveFilters) return; + + posts.forEach(post => { + const categories = (post.dataset.categories || '').split(',').filter(Boolean); + const tags = (post.dataset.tags || '').split(',').filter(Boolean); + let visible = true; + + if (activeFilterMap.has('category')) { + visible = visible && Array.from(activeFilterMap.get('category')) + .some(cat => categories.includes(cat)); + } + if (activeFilterMap.has('tag')) { + visible = visible && Array.from(activeFilterMap.get('tag')) + .some(tag => tags.includes(tag)); + } + + post.style.display = visible ? 'block' : 'none'; + post.classList.toggle('post-card-visible', visible); + post.classList.toggle('post-card-hidden', !visible); + }); + updateFilterStats(); + } + + function clearFilters() { + activeFilterMap.clear(); + chipColorMap.clear(); + activeFilters.innerHTML = ''; + categoryFilter.value = ''; + tagFilter.value = ''; + updateVisibility(); + updateSelectOptions(); + } + + categoryFilter.addEventListener('change', e => addFilter('category', e.target.value)); + tagFilter.addEventListener('change', e => addFilter('tag', e.target.value)); + clearFiltersBtn.addEventListener('click', clearFilters); + + updateFilterStats(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } +})(); diff --git a/assets/js/gallery-slider.js b/assets/js/gallery-slider.js new file mode 100644 index 0000000..5f5ca32 --- /dev/null +++ b/assets/js/gallery-slider.js @@ -0,0 +1,354 @@ +// Gallery slider widget — extracted from layouts/partials/gallery-slider.html. +// Initializes every `.gallery-slider` on the page, including its lightbox. +(function () { + 'use strict'; + + class GallerySlider { + constructor(element) { + this.element = element; + this.track = element.querySelector('.gallery-slider__track'); + this.slides = element.querySelectorAll('.gallery-slider__slide'); + this.indicators = element.querySelectorAll('.gallery-slider__indicator'); + this.thumbnails = element.querySelectorAll('.gallery-slider__thumbnail'); + this.prevBtn = element.querySelector('.gallery-slider__nav--prev'); + this.nextBtn = element.querySelector('.gallery-slider__nav--next'); + + // Lightbox elements + this.lightbox = element.querySelector('.gallery-lightbox'); + this.lightboxImage = this.lightbox.querySelector('.gallery-lightbox__image'); + this.lightboxCaption = this.lightbox.querySelector('.gallery-lightbox__caption'); + this.lightboxClose = this.lightbox.querySelector('.gallery-lightbox__close'); + this.lightboxPrev = this.lightbox.querySelector('.gallery-lightbox__nav--prev'); + this.lightboxNext = this.lightbox.querySelector('.gallery-lightbox__nav--next'); + this.lightboxCurrent = this.lightbox.querySelector('.gallery-lightbox__current'); + this.lightboxBackdrop = this.lightbox.querySelector('.gallery-lightbox__backdrop'); + + this.currentSlide = 0; + this.totalSlides = this.slides.length; + this.autoplay = element.dataset.autoplay === 'true'; + this.interval = parseInt(element.dataset.interval, 10) || 5000; + this.autoplayTimer = null; + this.isLightboxOpen = false; + this.containerHeight = null; + + this.imageData = Array.from(this.slides).map(slide => { + const img = slide.querySelector('.gallery-slider__image'); + const caption = slide.querySelector('.gallery-slider__caption'); + return { + src: img.src, + alt: img.alt, + caption: caption ? caption.innerHTML : '' + }; + }); + + this.init(); + } + + init() { + if (this.totalSlides <= 1) return; + + this.bindEvents(); + this.updateSlide(0); + this.calculateFixedHeight(); + + if (this.autoplay) this.startAutoplay(); + } + + calculateFixedHeight() { + const container = this.element.querySelector('.gallery-slider__container'); + const containerWidth = container.offsetWidth; + const minHeight = window.innerWidth <= 640 ? 200 : 250; + const maxHeight = window.innerHeight * 0.7; + + let smallestHeight = maxHeight; + let imagesLoaded = 0; + + this.slides.forEach(slide => { + const img = slide.querySelector('.gallery-slider__image'); + + const calculateHeight = () => { + if (img.naturalWidth && img.naturalHeight) { + const aspectRatio = img.naturalHeight / img.naturalWidth; + const displayHeight = containerWidth * aspectRatio; + const constrainedHeight = Math.max(minHeight, Math.min(displayHeight, maxHeight)); + smallestHeight = Math.min(smallestHeight, constrainedHeight); + } + imagesLoaded++; + if (imagesLoaded === this.totalSlides) { + this.containerHeight = smallestHeight; + this.setFixedContainerHeight(); + } + }; + + if (img.complete && img.naturalWidth) { + calculateHeight(); + } else { + img.addEventListener('load', calculateHeight, { once: true }); + img.addEventListener('error', () => { + imagesLoaded++; + if (imagesLoaded === this.totalSlides) { + this.containerHeight = smallestHeight; + this.setFixedContainerHeight(); + } + }, { once: true }); + } + }); + } + + setFixedContainerHeight() { + if (this.containerHeight) { + this.track.style.height = `${this.containerHeight}px`; + } + } + + bindEvents() { + this.prevBtn?.addEventListener('click', () => this.prevSlide()); + this.nextBtn?.addEventListener('click', () => this.nextSlide()); + + this.indicators.forEach((indicator, index) => { + indicator.addEventListener('click', () => this.goToSlide(index)); + }); + + // Open lightbox when clicking on the active slide image. + this.slides.forEach(slide => { + const img = slide.querySelector('.gallery-slider__image'); + img.addEventListener('click', () => this.openLightbox(this.currentSlide)); + img.style.cursor = 'pointer'; + }); + + this.thumbnails.forEach((thumbnail, index) => { + thumbnail.addEventListener('click', e => { + if (e.ctrlKey || e.metaKey || e.button === 2) { + e.preventDefault(); + this.goToSlide(index); + setTimeout(() => this.openLightbox(index), 50); + } else { + this.goToSlide(index); + } + }); + thumbnail.addEventListener('dblclick', e => { + e.preventDefault(); + this.goToSlide(index); + setTimeout(() => this.openLightbox(index), 50); + }); + }); + + // Lightbox close handlers. + this.lightboxClose?.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + this.closeLightbox(); + }); + this.lightboxBackdrop?.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + this.closeLightbox(); + }); + + const lightboxContainer = this.lightbox.querySelector('.gallery-lightbox__container'); + lightboxContainer?.addEventListener('click', e => { + if (e.target === lightboxContainer) { + e.preventDefault(); + e.stopPropagation(); + this.closeLightbox(); + } + }); + + const lightboxContent = this.lightbox.querySelector('.gallery-lightbox__content'); + lightboxContent?.addEventListener('click', e => e.stopPropagation()); + this.lightboxImage?.addEventListener('click', e => e.stopPropagation()); + this.lightboxCaption?.addEventListener('click', e => e.stopPropagation()); + + this.lightboxPrev?.addEventListener('click', () => this.lightboxPrevImage()); + this.lightboxNext?.addEventListener('click', () => this.lightboxNextImage()); + + this.keyboardHandler = e => { + if (this.isLightboxOpen) { + switch (e.key) { + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + this.closeLightbox(); + break; + case 'ArrowLeft': + e.preventDefault(); + this.lightboxPrevImage(); + break; + case 'ArrowRight': + e.preventDefault(); + this.lightboxNextImage(); + break; + } + } else if (this.element.contains(document.activeElement)) { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + this.prevSlide(); + break; + case 'ArrowRight': + e.preventDefault(); + this.nextSlide(); + break; + case 'Enter': + case ' ': + if (e.target.classList.contains('gallery-slider__image')) { + e.preventDefault(); + this.openLightbox(this.currentSlide); + } + break; + } + } + }; + document.addEventListener('keydown', this.keyboardHandler); + + this.element.addEventListener('mouseenter', () => this.pauseAutoplay()); + this.element.addEventListener('mouseleave', () => this.resumeAutoplay()); + + this.lastContainerWidth = this.element.querySelector('.gallery-slider__container').offsetWidth; + this.resizeHandler = () => { + if (this.isLightboxOpen) { + this.constrainImageToViewport(); + } else { + const container = this.element.querySelector('.gallery-slider__container'); + const currentWidth = container.offsetWidth; + if (Math.abs(currentWidth - this.lastContainerWidth) > 50) { + this.lastContainerWidth = currentWidth; + this.calculateFixedHeight(); + } + } + }; + window.addEventListener('resize', this.resizeHandler); + } + + openLightbox(index) { + this.isLightboxOpen = true; + const targetIndex = index !== undefined ? index : this.currentSlide; + this.updateLightboxImage(targetIndex); + this.lightbox.setAttribute('aria-hidden', 'false'); + this.lightbox.classList.add('gallery-lightbox--active'); + document.body.style.overflow = 'hidden'; + this.pauseAutoplay(); + setTimeout(() => this.lightboxClose?.focus(), 100); + } + + closeLightbox() { + this.isLightboxOpen = false; + this.lightbox.setAttribute('aria-hidden', 'true'); + this.lightbox.classList.remove('gallery-lightbox--active'); + document.body.style.overflow = ''; + this.resumeAutoplay(); + } + + updateLightboxImage(index) { + const safeIndex = Math.max(0, Math.min(index, this.totalSlides - 1)); + const imageData = this.imageData[safeIndex]; + + this.lightboxImage.src = imageData.src; + this.lightboxImage.alt = imageData.alt; + this.lightboxCaption.innerHTML = imageData.caption; + this.lightboxCurrent.textContent = String(safeIndex + 1); + + this.currentSlide = safeIndex; + + this.lightboxImage.onload = () => this.constrainImageToViewport(); + + const navDisplay = this.totalSlides > 1 ? 'flex' : 'none'; + this.lightboxPrev.style.display = navDisplay; + this.lightboxNext.style.display = navDisplay; + } + + constrainImageToViewport() { + const img = this.lightboxImage; + const padding = window.innerWidth <= 768 ? 120 : 160; + img.style.maxWidth = `${window.innerWidth - padding}px`; + img.style.maxHeight = `${window.innerHeight - padding}px`; + } + + lightboxPrevImage() { + const prevIndex = (this.currentSlide - 1 + this.totalSlides) % this.totalSlides; + this.updateLightboxImage(prevIndex); + this.updateSlide(prevIndex); + } + + lightboxNextImage() { + const nextIndex = (this.currentSlide + 1) % this.totalSlides; + this.updateLightboxImage(nextIndex); + this.updateSlide(nextIndex); + } + + updateSlide(index) { + this.slides.forEach((slide, i) => { + slide.classList.toggle('gallery-slider__slide--active', i === index); + }); + this.indicators.forEach((indicator, i) => { + indicator.classList.toggle('gallery-slider__indicator--active', i === index); + indicator.setAttribute('aria-selected', String(i === index)); + }); + this.thumbnails.forEach((thumbnail, i) => { + thumbnail.classList.toggle('gallery-slider__thumbnail--active', i === index); + }); + this.currentSlide = index; + this.scrollToActiveThumbnail(index); + } + + scrollToActiveThumbnail(index) { + if (this.thumbnails.length === 0) return; + const thumbnailsContainer = this.element.querySelector('.gallery-slider__thumbnails'); + if (!thumbnailsContainer) return; + const activeThumbnail = this.thumbnails[index]; + if (!activeThumbnail) return; + + const containerRect = thumbnailsContainer.getBoundingClientRect(); + const thumbnailRect = activeThumbnail.getBoundingClientRect(); + const containerCenter = containerRect.width / 2; + const thumbnailCenter = thumbnailRect.left - containerRect.left + (thumbnailRect.width / 2); + const scrollOffset = thumbnailCenter - containerCenter; + + thumbnailsContainer.scrollTo({ + left: thumbnailsContainer.scrollLeft + scrollOffset, + behavior: 'smooth' + }); + } + + nextSlide() { this.goToSlide((this.currentSlide + 1) % this.totalSlides); } + prevSlide() { this.goToSlide((this.currentSlide - 1 + this.totalSlides) % this.totalSlides); } + + goToSlide(index) { + if (index >= 0 && index < this.totalSlides) { + this.updateSlide(index); + this.resetAutoplay(); + } + } + + startAutoplay() { + if (!this.autoplay) return; + this.autoplayTimer = setInterval(() => this.nextSlide(), this.interval); + } + + pauseAutoplay() { + if (this.autoplayTimer) { + clearInterval(this.autoplayTimer); + this.autoplayTimer = null; + } + } + + resumeAutoplay() { + if (this.autoplay && !this.autoplayTimer) this.startAutoplay(); + } + + resetAutoplay() { + this.pauseAutoplay(); + this.resumeAutoplay(); + } + } + + function init() { + document.querySelectorAll('.gallery-slider').forEach(slider => new GallerySlider(slider)); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } +})(); diff --git a/assets/js/lda-analysis.js b/assets/js/lda-analysis.js index 60785de..abd2eb3 100644 --- a/assets/js/lda-analysis.js +++ b/assets/js/lda-analysis.js @@ -611,3 +611,83 @@ } }; })(); + +// --------------------------------------------------------------------------- +// LDA mobile interaction + tooltip module +// (Extracted from layouts/partials/lda-analysis.html inline script.) +// --------------------------------------------------------------------------- +(function () { + 'use strict'; + + function init() { + const toggleBtn = document.getElementById('toggle-view'); + const container = document.querySelector('.lda-container'); + const toggleText = document.querySelector('.toggle-text'); + const tooltip = document.getElementById('topic-tooltip'); + + if (toggleBtn && container && toggleText) { + const labelChart = toggleBtn.dataset.i18nChart || 'Switch to Chart View'; + const labelTable = toggleBtn.dataset.i18nTable || 'Switch to Table View'; + let showingTable = false; + toggleBtn.addEventListener('click', () => { + showingTable = !showingTable; + container.classList.toggle('show-table', showingTable); + toggleText.textContent = showingTable ? labelChart : labelTable; + }); + } + + if (!tooltip) return; + + window.showTopicTooltip = function (event, topicData) { + if (!topicData) return; + + const titleEl = tooltip.querySelector('.tooltip-title'); + const keywordsEl = tooltip.querySelector('.tooltip-keywords'); + const documentsEl = tooltip.querySelector('.tooltip-documents'); + const confidenceEl = tooltip.querySelector('.tooltip-confidence'); + + if (titleEl) titleEl.textContent = `Topic ${topicData.id}: ${topicData.label}`; + if (keywordsEl && topicData.keywords) { + keywordsEl.innerHTML = topicData.keywords + .map(k => `${k}`) + .join(''); + } + if (documentsEl) { + documentsEl.textContent = `${topicData.documentCount} documents • ${topicData.percentage}%`; + } + if (confidenceEl) { + confidenceEl.textContent = `Confidence: ${topicData.confidence}%`; + } + + const rect = event.target.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + let left = rect.left; + let top = rect.top - tooltipRect.height - 10; + if (left + tooltipRect.width > window.innerWidth) { + left = window.innerWidth - tooltipRect.width - 10; + } + if (left < 10) left = 10; + if (top < 10) top = rect.bottom + 10; + + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + tooltip.classList.add('visible'); + }; + + window.hideTopicTooltip = function () { + tooltip.classList.remove('visible'); + }; + + document.addEventListener('click', (event) => { + if (!event.target.closest('.topic-bar-container')) { + window.hideTopicTooltip(); + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } +})(); diff --git a/assets/js/zipfs-law.js b/assets/js/zipfs-law.js index 978bbf7..73b9604 100644 --- a/assets/js/zipfs-law.js +++ b/assets/js/zipfs-law.js @@ -688,3 +688,27 @@ function hideTooltip() { tooltip.style.display = 'none'; } } + +// Bootstrap: read the corpus JSON injected by the partial and initialize. +(function bootstrapZipfsLaw() { + function start() { + const dataNode = document.getElementById('zipf-data-words'); + if (!dataNode) return; // Partial not on this page + let words = []; + try { + const parsed = JSON.parse(dataNode.textContent || '[]'); + words = Array.isArray(parsed) ? parsed.filter(w => typeof w === 'string') : []; + } catch (err) { + console.error('Zipf: failed to parse corpus data:', err); + return; + } + if (typeof initializeZipfsLaw === 'function') { + initializeZipfsLaw(words); + } + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start, { once: true }); + } else { + start(); + } +})(); diff --git a/i18n/en.toml b/i18n/en.toml new file mode 100644 index 0000000..243748d --- /dev/null +++ b/i18n/en.toml @@ -0,0 +1,157 @@ +# PKB-theme English i18n strings. +# This file is owned by Unit 4 of the modernization batch; Unit 12 added +# widget-specific keys below. Keep the table-of-contents structure if Unit 4 +# expands this file. + +# --------------------------------------------------------------------------- +# Knowledge graph widget +# --------------------------------------------------------------------------- +[knowledge_graph_physics_controls] +other = "Physics Controls" + +[knowledge_graph_repulsion] +other = "Repulsion:" + +[knowledge_graph_link_distance] +other = "Link Distance:" + +[knowledge_graph_node_spacing] +other = "Node Spacing:" + +[knowledge_graph_center_gravity] +other = "Center Gravity:" + +[knowledge_graph_reset_default] +other = "Reset to Default" + +[knowledge_graph_filter_by] +other = "Filter by:" + +[knowledge_graph_all_nodes] +other = "All Nodes" + +[knowledge_graph_filter_category] +other = "Category" + +[knowledge_graph_filter_tag] +other = "Tag" + +[knowledge_graph_filter_search] +other = "Node Name" + +[knowledge_graph_search_placeholder] +other = "Search nodes..." + +[knowledge_graph_clear_filters] +other = "Clear Filters" + +[knowledge_graph_save_png] +other = "Save as PNG" + +[knowledge_graph_save_svg] +other = "Save as SVG" + +[knowledge_graph_save_jpeg] +other = "Save as JPEG" + +[knowledge_graph_filter_aria] +other = "Filter graph" + +[knowledge_graph_save_aria] +other = "Save graph as image" + +[knowledge_graph_fullscreen_aria] +other = "View graph in fullscreen" + +[knowledge_graph_close_fullscreen_aria] +other = "Close fullscreen view" + +# --------------------------------------------------------------------------- +# Zipf's law widget +# --------------------------------------------------------------------------- +[zipf_title] +other = "Content Analysis (Zipf's Law)" + +[zipf_word_freq_distribution] +other = "Word Frequency Distribution" + +[zipf_word_freq_analysis] +other = "Word Frequency Analysis" + +[zipf_rank] +other = "Rank" + +[zipf_word] +other = "Word" + +[zipf_freq] +other = "Freq (n/total)" + +[zipf_pr] +other = "Pr(%)" + +[zipf_ideal] +other = "Ideal" + +[zipf_save_png] +other = "Save as PNG" + +[zipf_save_svg] +other = "Save as SVG" + +[zipf_save_jpeg] +other = "Save as JPEG" + +# --------------------------------------------------------------------------- +# LDA topic analysis widget +# --------------------------------------------------------------------------- +[lda_title] +other = "Topic Analysis (LDA)" + +[lda_topic_distribution] +other = "Topic Distribution" + +[lda_topic_details] +other = "Topic Details" + +[lda_loading] +other = "Analyzing content using GPU-accelerated LDA..." + +[lda_switch_to_table] +other = "Switch to Table View" + +[lda_switch_to_chart] +other = "Switch to Chart View" + +# --------------------------------------------------------------------------- +# Filters widget +# --------------------------------------------------------------------------- +[filters_select_category] +other = "Select Category" + +[filters_select_tag] +other = "Select Tag" + +[filters_clear_all] +other = "Clear All" + +[filters_initial_message] +other = "Select a category or tag to view matching posts" + +[filters_items_found] +other = "items found" + +# --------------------------------------------------------------------------- +# Contribution calendar widget +# --------------------------------------------------------------------------- +[calendar_activity_title] +other = "Activity Calendar" + +[calendar_less] +other = "Less" + +[calendar_more] +other = "More" + +[calendar_no_activity] +other = "No activity" diff --git a/layouts/partials/contribution-calendar.html b/layouts/partials/contribution-calendar.html index 2bf91cb..224fdc4 100644 --- a/layouts/partials/contribution-calendar.html +++ b/layouts/partials/contribution-calendar.html @@ -1,91 +1,67 @@