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)',
}}
/>
-