diff --git a/backend/routes/publications.js b/backend/routes/publications.js index 65cece9..f921250 100644 --- a/backend/routes/publications.js +++ b/backend/routes/publications.js @@ -22,7 +22,7 @@ router.get('/', async (req, res) => { ...(search && { search }) }; - const response = await axios.get(url, { + const response = await axios.get(url, { params, headers: OPENALEX_HEADERS }); @@ -298,6 +298,81 @@ router.get('/keyword_trends', async (req, res) => { } }); + + +// Route to get conference information for a list of DOIs +router.get('/conference-info', async (req, res) => { + try { + console.log('Conference-info route called'); + const { dois } = req.query; + console.log('DOIs received:', dois); + + if (!dois) { + return res.status(400).json({ error: 'DOIs parameter is required' }); + } + + // Split the DOIs string and extract just the DOI part from URLs + const doiList = Array.isArray(dois) ? dois : dois.split(','); + const extractedDois = doiList.map(doiUrl => { + // Extract just the DOI part from the full URL + const doiMatch = doiUrl.match(/https:\/\/doi\.org\/(.+)/); + return doiMatch ? doiMatch[1] : doiUrl; + }); + + console.log('Extracted DOIs:', extractedDois); + const results = []; + + // Process each DOI with individual API calls + for (const doi of extractedDois) { + try { + console.log(`Processing DOI: ${doi}`); + const url = `https://api.crossref.org/works/${doi}`; + console.log(`CrossRef URL: ${url}`); + + const response = await axios.get(url); + console.log(`Response status: ${response.status}`); + + if (response.status !== 200) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = response.data; + const msg = data.message; + console.log(`Conference name: ${msg.event?.name}`); + + const info = { + doi: doi, + title: msg.title?.[0] || null, + container: msg['container-title']?.[0] || null, + type: msg.type || null, + publisher: msg.publisher || null, + year: msg.issued?.['date-parts']?.[0]?.[0] || null, + event: { + name: msg.event?.name || null + } + }; + + results.push(info); + + // Small delay to avoid overwhelming the API + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + console.error(`Error processing DOI ${doi}:`, error); + results.push({ + doi: doi, + error: 'Failed to fetch conference information' + }); + } + } + + console.log('Results:', results); + res.json({ results }); + } catch (error) { + console.error('Error in conference-info route:', error); + res.status(500).json({ error: 'Failed to fetch conference information' }); + } +}); + //get a single publication router.get('/:id', async (req, res) => { try { diff --git a/frontend/src/assets/styles/global.css b/frontend/src/assets/styles/global.css index ff813df..2a39353 100644 --- a/frontend/src/assets/styles/global.css +++ b/frontend/src/assets/styles/global.css @@ -39,8 +39,12 @@ body.dark { /* App layout */ .app { - width: 100%; + width: 100vw; min-height: 100vh; + margin: 0; + padding: 0; + overflow-x: hidden; + overflow-y: auto; } .main-content { @@ -51,9 +55,9 @@ body.dark { /* Utility classes */ .container { - max-width: var(--breakpoint-desktop); - margin: 0 auto; - padding: 0 var(--spacing-md); + width: 100vw; + margin: 0; + padding: 0; } .text-center { @@ -82,9 +86,11 @@ body.dark { html, body { - overflow-y: scroll !important; - /* Always show vertical scrollbar */ - height: 100%; + overflow-x: hidden !important; + overflow-y: auto !important; + /* Allow vertical scrolling, prevent horizontal overflow */ + height: 100vh; + width: 100vw; margin: 0; padding: 0; } @@ -92,25 +98,25 @@ body { /* For Webkit browsers (Chrome, Safari, Edge) */ html::-webkit-scrollbar, body::-webkit-scrollbar { - width: 12px; + width: 0px; + display: none; } html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb { - background: #ccc; - border-radius: 6px; + display: none; } html::-webkit-scrollbar-track, body::-webkit-scrollbar-track { - background: #f1f1f1; + display: none; } /* For Firefox */ html, body { - scrollbar-width: auto; - scrollbar-color: #ccc #f1f1f1; + scrollbar-width: none; + scrollbar-color: transparent transparent; } /* === DESIGN SYSTEM TOKENS (HSL) === */ @@ -323,11 +329,9 @@ body { /* Container */ .container { - max-width: 1200px; - margin-left: auto; - margin-right: auto; - padding-left: 1rem; - padding-right: 1rem; + width: 100%; + margin: 0; + padding: 0; } /* Responsive text center */ diff --git a/frontend/src/components/animated/Lighting/Lightining.js b/frontend/src/components/animated/Lighting/Lightining.js new file mode 100644 index 0000000..fcecbb4 --- /dev/null +++ b/frontend/src/components/animated/Lighting/Lightining.js @@ -0,0 +1,195 @@ +import { useRef, useEffect } from "react"; +import "./lightning.css"; + +const Lightning = ({ + hue = 230, + xOffset = 0, + speed = 1, + intensity = 1, + size = 1, +}) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resizeCanvas = () => { + canvas.width = canvas.clientWidth; + canvas.height = canvas.clientHeight; + }; + resizeCanvas(); + window.addEventListener("resize", resizeCanvas); + + const gl = canvas.getContext("webgl"); + if (!gl) { + console.error("WebGL not supported"); + return; + } + + const vertexShaderSource = ` + attribute vec2 aPosition; + void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); + } + `; + + const fragmentShaderSource = ` + precision mediump float; + uniform vec2 iResolution; + uniform float iTime; + uniform float uHue; + uniform float uXOffset; + uniform float uSpeed; + uniform float uIntensity; + uniform float uSize; + + #define OCTAVE_COUNT 10 + + vec3 hsv2rgb(vec3 c) { + vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0,4.0,2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + return c.z * mix(vec3(1.0), rgb, c.y); + } + + float hash11(float p) { + p = fract(p * .1031); + p *= p + 33.33; + p *= p + p; + return fract(p); + } + + float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * .1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + mat2 rotate2d(float theta) { + float c = cos(theta); + float s = sin(theta); + return mat2(c, -s, s, c); + } + + float noise(vec2 p) { + vec2 ip = floor(p); + vec2 fp = fract(p); + float a = hash12(ip); + float b = hash12(ip + vec2(1.0, 0.0)); + float c = hash12(ip + vec2(0.0, 1.0)); + float d = hash12(ip + vec2(1.0, 1.0)); + + vec2 t = smoothstep(0.0, 1.0, fp); + return mix(mix(a, b, t.x), mix(c, d, t.x), t.y); + } + + float fbm(vec2 p) { + float value = 0.0; + float amplitude = 0.5; + for (int i = 0; i < OCTAVE_COUNT; ++i) { + value += amplitude * noise(p); + p *= rotate2d(0.45); + p *= 2.0; + amplitude *= 0.5; + } + return value; + } + + void mainImage( out vec4 fragColor, in vec2 fragCoord ) { + vec2 uv = fragCoord / iResolution.xy; + uv = 2.0 * uv - 1.0; + uv.x *= iResolution.x / iResolution.y; + uv.x += uXOffset; + + uv += 2.0 * fbm(uv * uSize + 0.8 * iTime * uSpeed) - 1.0; + + float dist = abs(uv.x); + vec3 baseColor = hsv2rgb(vec3(uHue / 360.0, 0.7, 0.8)); + vec3 col = baseColor * pow(mix(0.0, 0.07, hash11(iTime * uSpeed)) / dist, 1.0) * uIntensity; + col = pow(col, vec3(1.0)); + fragColor = vec4(col, 1.0); + } + + void main() { + mainImage(gl_FragColor, gl_FragCoord.xy); + } + `; + + const compileShader = ( + source, + type + ) => { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error("Shader compile error:", gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + return shader; + }; + + const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER); + const fragmentShader = compileShader( + fragmentShaderSource, + gl.FRAGMENT_SHADER + ); + if (!vertexShader || !fragmentShader) return; + + const program = gl.createProgram(); + if (!program) return; + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error("Program linking error:", gl.getProgramInfoLog(program)); + return; + } + gl.useProgram(program); + + const vertices = new Float32Array([ + -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, + ]); + const vertexBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + + const aPosition = gl.getAttribLocation(program, "aPosition"); + gl.enableVertexAttribArray(aPosition); + gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0); + + const iResolutionLocation = gl.getUniformLocation(program, "iResolution"); + const iTimeLocation = gl.getUniformLocation(program, "iTime"); + const uHueLocation = gl.getUniformLocation(program, "uHue"); + const uXOffsetLocation = gl.getUniformLocation(program, "uXOffset"); + const uSpeedLocation = gl.getUniformLocation(program, "uSpeed"); + const uIntensityLocation = gl.getUniformLocation(program, "uIntensity"); + const uSizeLocation = gl.getUniformLocation(program, "uSize"); + + const startTime = performance.now(); + const render = () => { + resizeCanvas(); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.uniform2f(iResolutionLocation, canvas.width, canvas.height); + const currentTime = performance.now(); + gl.uniform1f(iTimeLocation, (currentTime - startTime) / 1000.0); + gl.uniform1f(uHueLocation, hue); + gl.uniform1f(uXOffsetLocation, xOffset); + gl.uniform1f(uSpeedLocation, speed); + gl.uniform1f(uIntensityLocation, intensity); + gl.uniform1f(uSizeLocation, size); + gl.drawArrays(gl.TRIANGLES, 0, 6); + requestAnimationFrame(render); + }; + requestAnimationFrame(render); + + return () => { + window.removeEventListener("resize", resizeCanvas); + }; + }, [hue, xOffset, speed, intensity, size]); + + return ; +}; + +export default Lightning; diff --git a/frontend/src/components/animated/Lighting/lightning.css b/frontend/src/components/animated/Lighting/lightning.css new file mode 100644 index 0000000..9e07656 --- /dev/null +++ b/frontend/src/components/animated/Lighting/lightning.css @@ -0,0 +1,5 @@ +.lightning-container { + width: 100%; + height: 100%; + position: relative; +} \ No newline at end of file diff --git a/frontend/src/components/animated/TextType/texttype.css b/frontend/src/components/animated/TextType/texttype.css new file mode 100644 index 0000000..f04c0ff --- /dev/null +++ b/frontend/src/components/animated/TextType/texttype.css @@ -0,0 +1,14 @@ +.text-type { + display: inline-block; + white-space: pre-wrap; +} + +.text-type__cursor { + margin-left: 0.25rem; + display: inline-block; + opacity: 1; +} + +.text-type__cursor--hidden { + display: none; +} \ No newline at end of file diff --git a/frontend/src/components/animated/TextType/texttype.js b/frontend/src/components/animated/TextType/texttype.js new file mode 100644 index 0000000..96d99aa --- /dev/null +++ b/frontend/src/components/animated/TextType/texttype.js @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useRef, useState, createElement } from "react"; +import { gsap } from "gsap"; +import "./texttype.css"; + +const TextType = ({ + text, + as: Component = "div", + typingSpeed = 50, + initialDelay = 0, + pauseDuration = 2000, + deletingSpeed = 30, + loop = true, + className = "", + showCursor = true, + hideCursorWhileTyping = false, + cursorCharacter = "|", + cursorClassName = "", + cursorBlinkDuration = 0.5, + textColors = [], + variableSpeed, + onSentenceComplete, + startOnVisible = false, + reverseMode = false, + ...props +}) => { + const [displayedText, setDisplayedText] = useState(""); + const [currentCharIndex, setCurrentCharIndex] = useState(0); + const [isDeleting, setIsDeleting] = useState(false); + const [currentTextIndex, setCurrentTextIndex] = useState(0); + const [isVisible, setIsVisible] = useState(!startOnVisible); + const cursorRef = useRef(null); + const containerRef = useRef(null); + + const textArray = Array.isArray(text) ? text : [text]; + + const getRandomSpeed = () => { + if (!variableSpeed) return typingSpeed; + const { min, max } = variableSpeed; + return Math.random() * (max - min) + min; + }; + + const getCurrentTextColor = () => { + if (textColors.length === 0) return "#ffffff"; + return textColors[currentTextIndex % textColors.length]; + }; + + useEffect(() => { + if (!startOnVisible || !containerRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + } + }); + }, + { threshold: 0.1 } + ); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [startOnVisible]); + + useEffect(() => { + if (showCursor && cursorRef.current) { + gsap.set(cursorRef.current, { opacity: 1 }); + gsap.to(cursorRef.current, { + opacity: 0, + duration: cursorBlinkDuration, + repeat: -1, + yoyo: true, + ease: "power2.inOut", + }); + } + }, [showCursor, cursorBlinkDuration]); + + useEffect(() => { + if (!isVisible) return; + + let timeout; + const currentText = textArray[currentTextIndex]; + const processedText = reverseMode + ? currentText.split("").reverse().join("") + : currentText; + + const executeTypingAnimation = () => { + if (isDeleting) { + if (displayedText === "") { + setIsDeleting(false); + if (currentTextIndex === textArray.length - 1 && !loop) { + return; + } + + if (onSentenceComplete) { + onSentenceComplete(textArray[currentTextIndex], currentTextIndex); + } + + setCurrentTextIndex((prev) => (prev + 1) % textArray.length); + setCurrentCharIndex(0); + timeout = setTimeout(() => { }, pauseDuration); + } else { + timeout = setTimeout(() => { + setDisplayedText((prev) => prev.slice(0, -1)); + }, deletingSpeed); + } + } else { + if (currentCharIndex < processedText.length) { + timeout = setTimeout( + () => { + setDisplayedText( + (prev) => prev + processedText[currentCharIndex] + ); + setCurrentCharIndex((prev) => prev + 1); + }, + variableSpeed ? getRandomSpeed() : typingSpeed + ); + } else if (textArray.length > 1) { + timeout = setTimeout(() => { + setIsDeleting(true); + }, pauseDuration); + } + } + }; + + if (currentCharIndex === 0 && !isDeleting && displayedText === "") { + timeout = setTimeout(executeTypingAnimation, initialDelay); + } else { + executeTypingAnimation(); + } + + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + currentCharIndex, + displayedText, + isDeleting, + typingSpeed, + deletingSpeed, + pauseDuration, + textArray, + currentTextIndex, + loop, + initialDelay, + isVisible, + reverseMode, + variableSpeed, + onSentenceComplete, + ]); + + const shouldHideCursor = + hideCursorWhileTyping && + (currentCharIndex < textArray[currentTextIndex].length || isDeleting); + + return createElement( + Component, + { + ref: containerRef, + className: `text-type ${className}`, + ...props, + }, + + {displayedText} + , + showCursor && ( + + {cursorCharacter} + + ) + ); +}; + +export default TextType; diff --git a/frontend/src/components/shared/ApiCallInfoBox.jsx b/frontend/src/components/shared/ApiCallInfoBox.jsx index 0d55daf..12af74c 100644 --- a/frontend/src/components/shared/ApiCallInfoBox.jsx +++ b/frontend/src/components/shared/ApiCallInfoBox.jsx @@ -27,7 +27,7 @@ const apiCallInfoBox = ({ userInputs, apiCalls, darkMode = true }) => { ℹ️ API Call Information - + {userInputs.length > 0 && (
@@ -35,8 +35,8 @@ const apiCallInfoBox = ({ userInputs, apiCalls, darkMode = true }) => {
{userInputs.map((input, index) => ( -
@@ -46,9 +46,21 @@ const apiCallInfoBox = ({ userInputs, apiCalls, darkMode = true }) => {
)} - + {apiCalls.length > 0 && (
+
+ ℹ️ Data Source: All results are fetched from OpenAlex +
The results displayed on this page are retrieved from the following database search:
diff --git a/frontend/src/components/shared/DropdownTrigger/DropdownTrigger.jsx b/frontend/src/components/shared/DropdownTrigger/DropdownTrigger.jsx index d51a52b..3c00f6c 100644 --- a/frontend/src/components/shared/DropdownTrigger/DropdownTrigger.jsx +++ b/frontend/src/components/shared/DropdownTrigger/DropdownTrigger.jsx @@ -5,6 +5,7 @@ const DropdownTrigger = ({ value, placeholder, onClick, + onClear, darkMode = false, disabled = false }) => { @@ -12,11 +13,24 @@ const DropdownTrigger = ({
{value || placeholder} + {value && onClear && ( + + )}
); diff --git a/frontend/src/components/shared/DropdownTrigger/DropdownTrigger.module.css b/frontend/src/components/shared/DropdownTrigger/DropdownTrigger.module.css index 7cd3ced..c338723 100644 --- a/frontend/src/components/shared/DropdownTrigger/DropdownTrigger.module.css +++ b/frontend/src/components/shared/DropdownTrigger/DropdownTrigger.module.css @@ -43,6 +43,27 @@ transition: transform 0.15s; } +.clearButton { + position: absolute; + right: 2rem; + background: none; + border: none; + color: #aaa; + font-size: 1.1rem; + cursor: pointer; + padding: 0 0.25rem; + z-index: 2; + line-height: 1; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; +} +.clearButton:hover { + color: #f44336; +} + .triggerContainer:hover .triggerArrow { color: #4F6AF6; } \ No newline at end of file diff --git a/frontend/src/components/shared/SearchForm.jsx b/frontend/src/components/shared/SearchForm.jsx index 73b7bf2..498c1b6 100644 --- a/frontend/src/components/shared/SearchForm.jsx +++ b/frontend/src/components/shared/SearchForm.jsx @@ -8,7 +8,11 @@ const SearchForm = ({ searchKeyword, setSearchKeyword, author, + setAuthor, + setAuthorObject, institution, + setInstitution, + setInstitutionObject, onSearch, onOpenAdvancedFilters, onAuthorClick, @@ -100,6 +104,10 @@ const SearchForm = ({ value={author} placeholder="Click to search authors..." onClick={onAuthorClick} + onClear={author ? () => { + setAuthor(""); + if (typeof setAuthorObject === 'function') setAuthorObject(null); + } : undefined} darkMode={darkMode} />
@@ -116,6 +124,10 @@ const SearchForm = ({ value={institution} placeholder="Click to search institutions..." onClick={onInstitutionClick} + onClear={institution ? () => { + setInstitution(""); + if (typeof setInstitutionObject === 'function') setInstitutionObject(null); + } : undefined} darkMode={darkMode} />
diff --git a/frontend/src/pages/collaboration_graph.js b/frontend/src/pages/collaboration_graph.js index 27809ce..ba2435a 100644 --- a/frontend/src/pages/collaboration_graph.js +++ b/frontend/src/pages/collaboration_graph.js @@ -11,25 +11,31 @@ const GraphViewLight = ({ darkMode = true }) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [hoverLink, setHoverLink] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); const [triggerSearch, setTriggerSearch] = useState(false); - const [selectedJournal, setSelectedJournal] = useState(null); - const [journalInput, setJournalInput] = useState(''); - const [journalSuggestions, setJournalSuggestions] = useState([]); - const [showJournalModal, setShowJournalModal] = useState(false); - const [modalJournalInput, setModalJournalInput] = useState(''); - const [modalJournalSuggestions, setModalJournalSuggestions] = useState([]); + const [selectedAuthor, setSelectedAuthor] = useState(null); + const [authorInput, setAuthorInput] = useState(''); + const [authorSuggestions, setAuthorSuggestions] = useState([]); + const [showAuthorModal, setShowAuthorModal] = useState(false); + const [modalAuthorInput, setModalAuthorInput] = useState(''); + const [modalAuthorSuggestions, setModalAuthorSuggestions] = useState([]); const inputRef = useRef(null); const fgRef = useRef(); const [selectedEdge, setSelectedEdge] = useState(null); const [collabPapers, setCollabPapers] = useState([]); const [papersLoading, setPapersLoading] = useState(false); const [papersError, setPapersError] = useState(null); + const [conferenceInfo, setConferenceInfo] = useState({}); + const [conferenceLoading, setConferenceLoading] = useState(false); const [showInstitutionModal, setShowInstitutionModal] = useState(false); const [modalInstitutionInput, setModalInstitutionInput] = useState(''); const [modalInstitutionSuggestions, setModalInstitutionSuggestions] = useState([]); const [hasSearched, setHasSearched] = useState(false); - const [showJournalsInGraph, setShowJournalsInGraph] = useState(true); + const [showAuthorsInGraph, setShowAuthorsInGraph] = useState(true); + + // Store works data globally for consistency + const [worksData, setWorksData] = useState([]); // Disclaimer state const [userInputs, setUserInputs] = useState([]); @@ -42,23 +48,23 @@ const GraphViewLight = ({ darkMode = true }) => { } }, [graphData]); - // Journal autocomplete suggestions + // Author autocomplete suggestions useEffect(() => { - const fetchJournals = async () => { - if (journalInput.length < 2) { - setJournalSuggestions([]); + const fetchAuthors = async () => { + if (authorInput.length < 2) { + setAuthorSuggestions([]); return; } try { - const res = await fetch(`https://api.openalex.org/sources?filter=type:journal&search=${encodeURIComponent(journalInput)}&per_page=10`); + const res = await fetch(`https://api.openalex.org/authors?search=${encodeURIComponent(authorInput)}&per_page=10`); const data = await res.json(); - setJournalSuggestions(data.results || []); + setAuthorSuggestions(data.results || []); } catch { - setJournalSuggestions([]); + setAuthorSuggestions([]); } }; - fetchJournals(); - }, [journalInput]); + fetchAuthors(); + }, [authorInput]); // Only generate the graph when triggerSearch changes and selectedInstitution is set @@ -71,7 +77,7 @@ const GraphViewLight = ({ darkMode = true }) => { const inputs = []; if (selectedInstitution && selectedInstitution.display_name) inputs.push({ category: 'Institution', value: selectedInstitution.display_name }); if (searchTerm.trim()) inputs.push({ category: 'Keyword', value: searchTerm.trim() }); - if (selectedJournal && selectedJournal.display_name) inputs.push({ category: 'Journal', value: selectedJournal.display_name }); + if (selectedAuthor && selectedAuthor.display_name) inputs.push({ category: 'Author', value: selectedAuthor.display_name }); setUserInputs(inputs); // Track API calls for disclaimer @@ -97,10 +103,10 @@ const GraphViewLight = ({ darkMode = true }) => { if (searchTerm.trim()) { filterParts.push(`title_and_abstract.search:${encodeURIComponent(searchTerm.trim())}`); } - if (selectedJournal && selectedJournal.id) { - // Use OpenAlex source id for journal filter - const sourceId = selectedJournal.id.split('/').pop(); - filterParts.push(`primary_location.source.id:${sourceId}`); + if (selectedAuthor && selectedAuthor.id) { + // Use OpenAlex author id for author filter + const authorId = selectedAuthor.id.split('/').pop(); + filterParts.push(`authorships.author.id:${authorId}`); } const filterString = filterParts.join(','); const url = `https://api.openalex.org/works?filter=${filterString}&group_by=authorships.institutions.id&per_page=200`; @@ -125,18 +131,18 @@ const GraphViewLight = ({ darkMode = true }) => { return data.results || []; }; - // Fetch all works for the institution (and optionally journal filter) + // Fetch all works for the institution (and optionally author filter) const fetchWorks = async (institutionId, collaboratorIds) => { let filterParts = [`authorships.institutions.id:${institutionId}`]; if (searchTerm.trim()) { filterParts.push(`title_and_abstract.search:${encodeURIComponent(searchTerm.trim())}`); } - if (selectedJournal && selectedJournal.id) { - const sourceId = selectedJournal.id.split('/').pop(); - filterParts.push(`primary_location.source.id:${sourceId}`); + if (selectedAuthor && selectedAuthor.id) { + const authorId = selectedAuthor.id.split('/').pop(); + filterParts.push(`authorships.author.id:${authorId}`); } // Only fetch works for top collaborators - if ((!selectedJournal || !selectedJournal.id) && collaboratorIds.length > 0) { + if ((!selectedAuthor || !selectedAuthor.id) && collaboratorIds.length > 0) { filterParts.push(`authorships.institutions.id:${collaboratorIds.map(id => id).join('|')}`); } const filterString = filterParts.join(','); @@ -162,43 +168,138 @@ const GraphViewLight = ({ darkMode = true }) => { const topCollaborators = await fetchTopCollaborators(institutionId); const collaboratorIds = topCollaborators.map(c => c.key.split('/').pop()); const collaboratorDetails = await fetchInstitutionDetails(collaboratorIds); - if (showJournalsInGraph) { - // No journal selected: show top 10 collaborators and all unique sources as nodes + + if (showAuthorsInGraph) { + // Show top 10 collaborators and all unique authors as nodes const works = await fetchWorks(institutionId, collaboratorIds); - // Collect all unique sources from works - const sourceMap = new Map(); // id -> display_name + + // Store works data globally for consistency + setWorksData(works); + + // Collect all unique authors from works + const authorMap = new Map(); // id -> display_name works.forEach(work => { - (work.locations || []).forEach(loc => { - if (loc.source && loc.source.id && loc.source.display_name) { - sourceMap.set(loc.source.id, loc.source.display_name); + work.authorships?.forEach(authorship => { + if (authorship.author && authorship.author.id && authorship.author.display_name) { + authorMap.set(authorship.author.id, authorship.author.display_name); } }); }); + // Build institution nodes const nodes = [ { id: institutionId, label: selectedInstitution.display_name, type: 'institution', main: true }, ...collaboratorDetails .filter(inst => inst.id.split('/').pop() !== institutionId) .map(inst => ({ id: inst.id.split('/').pop(), label: inst.display_name, type: 'institution' })), - ...Array.from(sourceMap.entries()).map(([id, label]) => ({ id, label, type: 'source' })) - ]; - // Build links: institution-to-institution and institution-to-source - const links = [ - ...topCollaborators.map(c => ({ source: institutionId, target: c.key.split('/').pop(), value: c.count })), + ...Array.from(authorMap.entries()).map(([id, label]) => ({ id, label, type: 'author' })) ]; - // Add institution-to-source links + + // Build links: institution-to-institution and institution-to-author + const links = []; + + // Add institution-to-institution links with accurate counts + for (const collaborator of topCollaborators) { + const collaboratorId = collaborator.key.split('/').pop(); + try { + const filterString = `authorships.institutions.lineage:i${institutionId},authorships.institutions.lineage:i${collaboratorId}`; + const res = await fetch( + `https://api.openalex.org/works?filter=${filterString}&per_page=1` + ); + const data = await res.json(); + const count = data.meta?.count || 0; + + links.push({ + source: institutionId, + target: collaboratorId, + value: count + }); + } catch (e) { + console.error(`Error fetching count for institution collaboration ${institutionId}-${collaboratorId}:`, e); + // Fallback to the original count + links.push({ + source: institutionId, + target: collaboratorId, + value: collaborator.count + }); + } + } + + // Add institution-to-author links and detect institution-to-institution collaborations + const authorInstitutionMap = new Map(); // key: "instId-authorId", value: count + const institutionCollaborations = new Map(); // key: "instId1-instId2", value: count + + // Analyze works to find both author-institution and institution-institution collaborations works.forEach(work => { - const instIds = work.authorships?.flatMap(auth => auth.institutions.map(inst => inst.id.split('/').pop())) || []; - (work.locations || []).forEach(loc => { - if (loc.source && loc.source.id) { - instIds.forEach(instId => { + const workInstitutions = new Set(); + const workAuthors = new Set(); + + work.authorships?.forEach(authorship => { + if (authorship.author && authorship.author.id) { + const authorId = authorship.author.id; + workAuthors.add(authorId); + + authorship.institutions?.forEach(inst => { + const instId = inst.id.split('/').pop(); if (instId === institutionId || collaboratorIds.includes(instId)) { - links.push({ source: instId, target: loc.source.id, value: 1, type: 'published_in' }); + workInstitutions.add(instId); + authorInstitutionMap.set(`${instId}-${authorId}`, (authorInstitutionMap.get(`${instId}-${authorId}`) || 0) + 1); } }); } }); + + // If this work involves multiple institutions, create institution-to-institution collaboration + if (workInstitutions.size > 1) { + const institutions = Array.from(workInstitutions); + for (let i = 0; i < institutions.length; i++) { + for (let j = i + 1; j < institutions.length; j++) { + const key = [institutions[i], institutions[j]].sort().join('-'); + institutionCollaborations.set(key, (institutionCollaborations.get(key) || 0) + 1); + } + } + } }); + + // Add institution-to-institution links based on actual collaborations found + for (const [collabKey, count] of institutionCollaborations) { + const [inst1, inst2] = collabKey.split('-'); + links.push({ + source: inst1, + target: inst2, + value: count + }); + } + + // Add institution-to-author links using API calls for accurate counts + for (const [pair, count] of authorInstitutionMap) { + const [instId, authorId] = pair.split('-'); + try { + const filterString = `authorships.author.id:${authorId},authorships.institutions.lineage:i${instId}`; + const res = await fetch( + `https://api.openalex.org/works?filter=${filterString}&per_page=1` + ); + const data = await res.json(); + const apiCount = data.meta?.count || 0; + + links.push({ + source: instId, + target: authorId, + value: apiCount, + type: 'published_by' + }); + } catch (e) { + console.error(`Error fetching count for ${pair}:`, e); + // Fallback to the count from our analysis + links.push({ + source: instId, + target: authorId, + value: count, + type: 'published_by' + }); + } + } + setGraphData({ nodes, links }); } else { // Only add institution nodes and institution-to-institution links @@ -212,7 +313,7 @@ const GraphViewLight = ({ darkMode = true }) => { setGraphData({ nodes, links }); return; } - + // Set API calls for disclaimer setApiCalls(apiCallUrls); } catch (e) { @@ -239,6 +340,39 @@ const GraphViewLight = ({ darkMode = true }) => { return graphData.nodes.find(n => n.id === id)?.label || ''; }; + // Function to fetch conference information for papers with DOIs + const fetchConferenceInfo = async (papers) => { + const papersWithDoi = papers.filter(paper => paper.doi); + console.log('Papers with DOI:', papersWithDoi); + if (papersWithDoi.length === 0) return; + + setConferenceLoading(true); + try { + // Send the full DOI URLs to the backend + const dois = papersWithDoi.map(paper => paper.doi); + console.log('DOIs to fetch:', dois); + const response = await fetch(`/api/publications/conference-info?dois=${dois.join(',')}`); + const data = await response.json(); + console.log('Conference API response:', data); + + const conferenceData = {}; + data.results.forEach(result => { + if (result.doi && !result.error) { + // Store with the full DOI URL as key to match the paper.doi format + const fullDoiUrl = `https://doi.org/${result.doi}`; + conferenceData[fullDoiUrl] = result; + } + }); + console.log('Processed conference data:', conferenceData); + + setConferenceInfo(conferenceData); + } catch (error) { + console.error('Error fetching conference information:', error); + } finally { + setConferenceLoading(false); + } + }; + return (
{/* Orbit as full-page background */} @@ -251,14 +385,24 @@ const GraphViewLight = ({ darkMode = true }) => { height: '100vh', zIndex: 0, pointerEvents: 'none', + transform: 'translateZ(0)', + willChange: 'transform', }} > - +
+ +
{/* Main content */} @@ -349,13 +493,38 @@ const GraphViewLight = ({ darkMode = true }) => { }}> {selectedInstitution ? selectedInstitution.display_name : "Click to search institutions..."} + {selectedInstitution && ( + + )} - {/* Journal Dropdown */} + {/* Author Dropdown */}
- +
{ alignItems: 'center' }} onClick={() => { - setShowJournalModal(true); - setModalJournalInput(''); - setModalJournalSuggestions([]); + setShowAuthorModal(true); + setModalAuthorInput(''); + setModalAuthorSuggestions([]); }} > - {journalInput || "Click to search journals..."} + {authorInput || "Click to search authors..."} + {selectedAuthor && ( + + )}
@@ -457,13 +652,13 @@ const GraphViewLight = ({ darkMode = true }) => { }}>
{ - setShowJournalsInGraph(!showJournalsInGraph); + setShowAuthorsInGraph(!showAuthorsInGraph); setTriggerSearch(s => !s); }} style={{ width: 50, height: 24, - backgroundColor: showJournalsInGraph ? '#4F6AF6' : '#666', + backgroundColor: showAuthorsInGraph ? '#4F6AF6' : '#666', borderRadius: 12, position: 'relative', cursor: 'pointer', @@ -479,14 +674,14 @@ const GraphViewLight = ({ darkMode = true }) => { borderRadius: '50%', position: 'absolute', top: 3, - left: showJournalsInGraph ? 29 : 3, + left: showAuthorsInGraph ? 29 : 3, transition: 'left 0.3s ease', boxShadow: '0 2px 4px rgba(0,0,0,0.2)', }} />
-